diff options
Diffstat (limited to 'mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt')
-rw-r--r-- | mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt | 2989 |
1 files changed, 2989 insertions, 0 deletions
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt new file mode 100644 index 0000000000..65952ecfb2 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt @@ -0,0 +1,2989 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.core.IsEqual.equalTo +import org.hamcrest.core.StringEndsWith.endsWith +import org.json.JSONObject +import org.junit.Assert.* // ktlint-disable no-wildcard-imports +import org.junit.Assume.assumeThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.* // ktlint-disable no-wildcard-imports +import org.mozilla.geckoview.GeckoSession.NavigationDelegate +import org.mozilla.geckoview.GeckoSession.PermissionDelegate +import org.mozilla.geckoview.GeckoSession.ProgressDelegate +import org.mozilla.geckoview.WebExtension.* // ktlint-disable no-wildcard-imports +import org.mozilla.geckoview.WebExtension.BrowsingDataDelegate.Type.* // ktlint-disable no-wildcard-imports +import org.mozilla.geckoview.WebExtensionController.EnableSource +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.RejectedPromiseException +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.Setting +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay +import org.mozilla.geckoview.test.util.RuntimeCreator +import org.mozilla.geckoview.test.util.UiThreadUtils +import java.nio.charset.Charset +import java.util.* // ktlint-disable no-wildcard-imports +import java.util.concurrent.CancellationException +import kotlin.collections.HashMap + +@RunWith(AndroidJUnit4::class) +@MediumTest +class WebExtensionTest : BaseSessionTest() { + companion object { + private const val TABS_CREATE_BACKGROUND: String = + "resource://android/assets/web_extensions/tabs-create/" + private const val TABS_CREATE_2_BACKGROUND: String = + "resource://android/assets/web_extensions/tabs-create-2/" + private const val TABS_CREATE_REMOVE_BACKGROUND: String = + "resource://android/assets/web_extensions/tabs-create-remove/" + private const val TABS_ACTIVATE_REMOVE_BACKGROUND: String = + "resource://android/assets/web_extensions/tabs-activate-remove/" + private const val TABS_REMOVE_BACKGROUND: String = + "resource://android/assets/web_extensions/tabs-remove/" + private const val MESSAGING_BACKGROUND: String = + "resource://android/assets/web_extensions/messaging/" + private const val MESSAGING_CONTENT: String = + "resource://android/assets/web_extensions/messaging-content/" + private const val OPENOPTIONSPAGE_1_BACKGROUND: String = + "resource://android/assets/web_extensions/openoptionspage-1/" + private const val OPENOPTIONSPAGE_2_BACKGROUND: String = + "resource://android/assets/web_extensions/openoptionspage-2/" + private const val EXTENSION_PAGE_RESTORE: String = + "resource://android/assets/web_extensions/extension-page-restore/" + private const val BROWSING_DATA: String = + "resource://android/assets/web_extensions/browsing-data-built-in/" + } + + private val controller + get() = sessionRule.runtime.webExtensionController + + @Before + fun setup() { + sessionRule.setPrefsUntilTestEnd(mapOf("extensions.isembedded" to true)) + sessionRule.runtime.webExtensionController.setTabActive(mainSession, true) + } + + @Test + fun installBuiltIn() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + // First let's check that the color of the border is empty before loading + // the WebExtension + assertBodyBorderEqualTo("") + + // Load the WebExtension that will add a border to the body + val borderify = sessionRule.waitForResult( + controller.installBuiltIn( + "resource://android/assets/web_extensions/borderify/", + ), + ) + + assertTrue(borderify.isBuiltIn) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was applied by checking the border color + assertBodyBorderEqualTo("red") + + // Uninstall WebExtension and check again + sessionRule.waitForResult(controller.uninstall(borderify)) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was not applied after being uninstalled + assertBodyBorderEqualTo("") + } + + private fun assertBodyBorderEqualTo(expected: String) { + val color = mainSession.evaluateJS("document.body.style.borderColor") + assertThat( + "The border color should be '$expected'", + color as String, + equalTo(expected), + ) + } + + private fun checkDisabledState( + extension: WebExtension, + userDisabled: Boolean = false, + appDisabled: Boolean = false, + blocklistDisabled: Boolean = false, + ) { + val enabled = !userDisabled && !appDisabled && !blocklistDisabled + + mainSession.reload() + sessionRule.waitForPageStop() + + if (!enabled) { + // Border should be empty because the extension is disabled + assertBodyBorderEqualTo("") + } else { + assertBodyBorderEqualTo("red") + } + + assertThat( + "enabled should match", + extension.metaData.enabled, + equalTo(enabled), + ) + assertThat( + "userDisabled should match", + extension.metaData.disabledFlags and DisabledFlags.USER > 0, + equalTo(userDisabled), + ) + assertThat( + "appDisabled should match", + extension.metaData.disabledFlags and DisabledFlags.APP > 0, + equalTo(appDisabled), + ) + assertThat( + "blocklistDisabled should match", + extension.metaData.disabledFlags and DisabledFlags.BLOCKLIST > 0, + equalTo(blocklistDisabled), + ) + } + + @Test + fun noDelegateErrorMessage() { + try { + sessionRule.evaluateExtensionJS( + """ + const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); + await browser.tabs.update(tab.id, { url: "www.google.com" }); + """, + ) + assertThat("tabs.update should not succeed", true, equalTo(false)) + } catch (ex: RejectedPromiseException) { + assertThat( + "Error message matches", + ex.message, + equalTo("Error: tabs.update is not supported"), + ) + } + + try { + sessionRule.evaluateExtensionJS( + """ + const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); + await browser.tabs.remove(tab.id); + """, + ) + assertThat("tabs.remove should not succeed", true, equalTo(false)) + } catch (ex: RejectedPromiseException) { + assertThat( + "Error message matches", + ex.message, + equalTo("Error: tabs.remove is not supported"), + ) + } + + try { + sessionRule.evaluateExtensionJS( + """ + await browser.runtime.openOptionsPage(); + """, + ) + assertThat( + "runtime.openOptionsPage should not succeed", + true, + equalTo(false), + ) + } catch (ex: RejectedPromiseException) { + assertThat( + "Error message matches", + ex.message, + equalTo("Error: runtime.openOptionsPage is not supported"), + ) + } + } + + @Test + fun enableDisable() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + return GeckoResult.allow() + } + }) + + sessionRule.addExternalDelegateUntilTestEnd( + WebExtensionController.AddonManagerDelegate::class, + { delegate -> controller.setAddonManagerDelegate(delegate) }, + { controller.setAddonManagerDelegate(null) }, + object : WebExtensionController.AddonManagerDelegate { + @AssertCalled(count = 3) + override fun onEnabling(extension: WebExtension) {} + + @AssertCalled(count = 3) + override fun onEnabled(extension: WebExtension) {} + + @AssertCalled(count = 3) + override fun onDisabling(extension: WebExtension) {} + + @AssertCalled(count = 3) + override fun onDisabled(extension: WebExtension) {} + + @AssertCalled(count = 1) + override fun onUninstalling(extension: WebExtension) {} + + @AssertCalled(count = 1) + override fun onUninstalled(extension: WebExtension) {} + + @AssertCalled(count = 1) + override fun onInstalling(extension: WebExtension) {} + + @AssertCalled(count = 1) + override fun onInstalled(extension: WebExtension) {} + }, + ) + + // First let's check that the color of the border is empty before loading + // the WebExtension + assertBodyBorderEqualTo("") + + var borderify = sessionRule.waitForResult( + controller.install("resource://android/assets/web_extensions/borderify.xpi"), + ) + checkDisabledState(borderify, userDisabled = false, appDisabled = false) + + borderify = sessionRule.waitForResult(controller.disable(borderify, EnableSource.USER)) + checkDisabledState(borderify, userDisabled = true, appDisabled = false) + + borderify = sessionRule.waitForResult(controller.disable(borderify, EnableSource.APP)) + checkDisabledState(borderify, userDisabled = true, appDisabled = true) + + borderify = sessionRule.waitForResult(controller.enable(borderify, EnableSource.APP)) + checkDisabledState(borderify, userDisabled = true, appDisabled = false) + + borderify = sessionRule.waitForResult(controller.enable(borderify, EnableSource.USER)) + checkDisabledState(borderify, userDisabled = false, appDisabled = false) + + borderify = sessionRule.waitForResult(controller.disable(borderify, EnableSource.APP)) + checkDisabledState(borderify, userDisabled = false, appDisabled = true) + + borderify = sessionRule.waitForResult(controller.enable(borderify, EnableSource.APP)) + checkDisabledState(borderify, userDisabled = false, appDisabled = false) + + sessionRule.waitForResult(controller.uninstall(borderify)) + mainSession.reload() + sessionRule.waitForPageStop() + + // Border should be empty because the extension is not installed anymore + assertBodyBorderEqualTo("") + } + + @Test + fun installWebExtension() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + // First let's check that the color of the border is empty before loading + // the WebExtension + assertBodyBorderEqualTo("") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + assertEquals( + extension.metaData.description, + "Adds a red border to all webpages matching example.com.", + ) + assertEquals(extension.metaData.name, "Borderify") + assertEquals(extension.metaData.version, "1.0") + assertEquals(extension.isBuiltIn, false) + assertEquals(extension.metaData.enabled, false) + assertEquals( + extension.metaData.signedState, + WebExtension.SignedStateFlags.SIGNED, + ) + assertEquals( + extension.metaData.blocklistState, + WebExtension.BlocklistStateFlags.NOT_BLOCKED, + ) + + return GeckoResult.allow() + } + }) + + val borderify = sessionRule.waitForResult( + controller.install("resource://android/assets/web_extensions/borderify.xpi"), + ) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was applied by checking the border color + assertBodyBorderEqualTo("red") + + var list = extensionsMap(sessionRule.waitForResult(controller.list())) + assertEquals(list.size, 2) + assertTrue(list.containsKey(borderify.id)) + assertTrue(list.containsKey(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID)) + + // Uninstall WebExtension and check again + sessionRule.waitForResult(controller.uninstall(borderify)) + + list = extensionsMap(sessionRule.waitForResult(controller.list())) + assertEquals(list.size, 1) + assertTrue(list.containsKey(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID)) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was not applied after being uninstalled + assertBodyBorderEqualTo("") + } + + @Test + @Setting.List(Setting(key = Setting.Key.USE_PRIVATE_MODE, value = "true")) + fun runInPrivateBrowsing() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + // Make sure border is empty before running the extension + assertBodyBorderEqualTo("") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled(count = 1) + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + return GeckoResult.allow() + } + }) + + var borderify = sessionRule.waitForResult( + controller.install("resource://android/assets/web_extensions/borderify.xpi"), + ) + + // Make sure private mode is enabled + assertTrue(mainSession.settings.usePrivateMode) + assertFalse(borderify.metaData.allowedInPrivateBrowsing) + // Check that the WebExtension was not applied to a private mode page + assertBodyBorderEqualTo("") + + borderify = sessionRule.waitForResult( + controller.setAllowedInPrivateBrowsing(borderify, true), + ) + + assertTrue(borderify.metaData.allowedInPrivateBrowsing) + // Check that the WebExtension was applied to a private mode page now that the extension + // is enabled in private mode + mainSession.reload() + sessionRule.waitForPageStop() + assertBodyBorderEqualTo("red") + + borderify = sessionRule.waitForResult( + controller.setAllowedInPrivateBrowsing(borderify, false), + ) + + assertFalse(borderify.metaData.allowedInPrivateBrowsing) + // Check that the WebExtension was not applied to a private mode page after being + // not allowed to run in private mode + mainSession.reload() + sessionRule.waitForPageStop() + assertBodyBorderEqualTo("") + + // Uninstall WebExtension and check again + sessionRule.waitForResult(controller.uninstall(borderify)) + mainSession.reload() + sessionRule.waitForPageStop() + assertBodyBorderEqualTo("") + } + + @Test + fun optionsPageMetadata() { + // dummy.xpi is not signed, but it could be + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + ), + ) + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled(count = 1) + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + return GeckoResult.allow() + } + }) + + val dummy = sessionRule.waitForResult( + controller.install("resource://android/assets/web_extensions/dummy.xpi"), + ) + + val metadata = dummy.metaData + assertTrue((metadata.optionsPageUrl ?: "").matches("^moz-extension://[0-9a-f\\-]*/options.html$".toRegex())) + assertEquals(metadata.openOptionsPageInTab, true) + assertTrue(metadata.baseUrl.matches("^moz-extension://[0-9a-f\\-]*/$".toRegex())) + + sessionRule.waitForResult(controller.uninstall(dummy)) + } + + @Test + fun installMultiple() { + // dummy.xpi is not signed, but it could be + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + ), + ) + + // First, make sure the list only contains the test support extension + var list = extensionsMap(sessionRule.waitForResult(controller.list())) + assertEquals(list.size, 1) + assertTrue(list.containsKey(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID)) + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled(count = 2) + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + return GeckoResult.allow() + } + }) + + // Install in parallell borderify and dummy + val borderifyResult = controller.install( + "resource://android/assets/web_extensions/borderify.xpi", + ) + val dummyResult = controller.install( + "resource://android/assets/web_extensions/dummy.xpi", + ) + + val (borderify, dummy) = sessionRule.waitForResult( + GeckoResult.allOf(borderifyResult, dummyResult), + ) + + // Make sure the list is updated accordingly + list = extensionsMap(sessionRule.waitForResult(controller.list())) + assertTrue(list.containsKey(borderify.id)) + assertTrue(list.containsKey(dummy.id)) + assertTrue(list.containsKey(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID)) + assertEquals(list.size, 3) + + // Uninstall borderify and verify that it's not in the list anymore + sessionRule.waitForResult(controller.uninstall(borderify)) + + list = extensionsMap(sessionRule.waitForResult(controller.list())) + assertEquals(list.size, 2) + assertTrue(list.containsKey(dummy.id)) + assertTrue(list.containsKey(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID)) + assertFalse(list.containsKey(borderify.id)) + + // Uninstall dummy and make sure the list is now empty + sessionRule.waitForResult(controller.uninstall(dummy)) + + list = extensionsMap(sessionRule.waitForResult(controller.list())) + assertEquals(list.size, 1) + assertTrue(list.containsKey(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID)) + } + + private fun testInstallError(name: String, expectedError: Int) { + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled(count = 0) + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + return GeckoResult.allow() + } + }) + + sessionRule.waitForResult( + controller.install("resource://android/assets/web_extensions/$name") + .accept({ + // We should not be able to install unsigned extensions + assertTrue(false) + }, { exception -> + val installException = exception as WebExtension.InstallException + assertEquals(installException.code, expectedError) + }), + ) + } + + private fun extensionsMap(extensionList: List<WebExtension>): Map<String, WebExtension> { + val map = HashMap<String, WebExtension>() + for (extension in extensionList) { + map.put(extension.id, extension) + } + return map + } + + @Test + fun installUnsignedExtensionSignatureNotRequired() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + ), + ) + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + return GeckoResult.allow() + } + }) + + val borderify = sessionRule.waitForResult( + controller.install( + "resource://android/assets/web_extensions/borderify-unsigned.xpi", + ) + .then { extension -> + assertEquals( + extension!!.metaData.signedState, + WebExtension.SignedStateFlags.MISSING, + ) + assertEquals( + extension.metaData.blocklistState, + WebExtension.BlocklistStateFlags.NOT_BLOCKED, + ) + assertEquals(extension.metaData.name, "Borderify") + GeckoResult.fromValue(extension) + }, + ) + + sessionRule.waitForResult(controller.uninstall(borderify)) + } + + @Test + fun installUnsignedExtensionSignatureRequired() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to true, + ), + ) + testInstallError( + "borderify-unsigned.xpi", + WebExtension.InstallException.ErrorCodes.ERROR_SIGNEDSTATE_REQUIRED, + ) + } + + @Test + fun installExtensionFileNotFound() { + testInstallError( + "file-not-found.xpi", + WebExtension.InstallException.ErrorCodes.ERROR_NETWORK_FAILURE, + ) + } + + @Test + fun installExtensionMissingId() { + testInstallError( + "borderify-missing-id.xpi", + WebExtension.InstallException.ErrorCodes.ERROR_CORRUPT_FILE, + ) + } + + @Test + fun installDeny() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + // Ensure border is empty to start. + assertBodyBorderEqualTo("") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled(count = 1) + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + return GeckoResult.deny() + } + }) + + sessionRule.waitForResult( + controller.install("resource://android/assets/web_extensions/borderify.xpi").accept({ + // We should not be able to install the extension. + assertTrue(false) + }, { exception -> + assertTrue(exception is WebExtension.InstallException) + val installException = exception as WebExtension.InstallException + assertEquals(installException.code, WebExtension.InstallException.ErrorCodes.ERROR_USER_CANCELED) + }), + ) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was not installed and the border is still empty. + assertBodyBorderEqualTo("") + } + + @Test + fun createNotification() { + sessionRule.delegateUntilTestEnd(object : WebNotificationDelegate { + @AssertCalled + override fun onShowNotification(notification: WebNotification) { + } + }) + + val extension = sessionRule.waitForResult( + controller.installBuiltIn("resource://android/assets/web_extensions/notification-test/"), + ) + + sessionRule.waitUntilCalled(object : WebNotificationDelegate { + @AssertCalled(count = 1) + override fun onShowNotification(notification: WebNotification) { + assertEquals(notification.title, "Time for cake!") + assertEquals(notification.text, "Something something cake") + assertEquals(notification.imageUrl, "https://example.com/img.svg") + // This should be filled out, Bug 1589693 + assertEquals(notification.source, null) + } + }) + + sessionRule.waitForResult( + controller.uninstall(extension), + ) + } + + // This test + // - Registers a web extension + // - Listens for messages and waits for a message + // - Sends a response to the message and waits for a second message + // - Verify that the second message has the correct value + // + // When `background == true` the test will be run using background messaging, otherwise the + // test will use content script messaging. + private fun testOnMessage(background: Boolean) { + val messageResult = GeckoResult<Void>() + + val prefix = if (background) "testBackground" else "testContent" + + val messageDelegate = object : WebExtension.MessageDelegate { + var awaitingResponse = false + var completed = false + + override fun onConnect(port: WebExtension.Port) { + // Ignored for this test + } + + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender, + ): GeckoResult<Any>? { + checkSender(nativeApp, sender, background) + + if (!awaitingResponse) { + assertThat( + "We should receive a message from the WebExtension", + message as String, + equalTo("${prefix}BrowserMessage"), + ) + awaitingResponse = true + return GeckoResult.fromValue("${prefix}MessageResponse") + } else if (!completed) { + assertThat( + "The background script should receive our message and respond", + message as String, + equalTo("response: ${prefix}MessageResponse"), + ) + messageResult.complete(null) + completed = true + } + return null + } + } + + val messaging = installWebExtension(background, messageDelegate) + sessionRule.waitForResult(messageResult) + + sessionRule.waitForResult(controller.uninstall(messaging)) + } + + // This test + // - Listen for a new tab request from a web extension + // - Registers a web extension + // - Waits for onNewTab request + // - Verify that request came from right extension + @Test + fun testBrowserTabsCreate() { + val tabsCreateResult = GeckoResult<Void>() + var tabsExtension: WebExtension? = null + val tabDelegate = object : WebExtension.TabDelegate { + override fun onNewTab(source: WebExtension, details: WebExtension.CreateTabDetails): GeckoResult<GeckoSession> { + assertEquals(details.url, "https://www.mozilla.org/en-US/") + assertEquals(details.active, true) + assertEquals(tabsExtension!!, source) + tabsCreateResult.complete(null) + return GeckoResult.fromValue(null) + } + } + + tabsExtension = sessionRule.waitForResult(controller.installBuiltIn(TABS_CREATE_BACKGROUND)) + tabsExtension.setTabDelegate(tabDelegate) + sessionRule.waitForResult(tabsCreateResult) + + sessionRule.waitForResult(controller.uninstall(tabsExtension)) + } + + // This test + // - Listen for a new tab request from a web extension + // - Registers a web extension + // - Extension requests creation of new tab with a cookie store id. + // - Waits for onNewTab request + // - Verify that request came from right extension + @Test + fun testBrowserTabsCreateWithCookieStoreId() { + sessionRule.setPrefsUntilTestEnd(mapOf("privacy.userContext.enabled" to true)) + val tabsCreateResult = GeckoResult<Void>() + var tabsExtension: WebExtension? = null + val tabDelegate = object : WebExtension.TabDelegate { + override fun onNewTab(source: WebExtension, details: WebExtension.CreateTabDetails): GeckoResult<GeckoSession> { + assertEquals(details.url, "https://www.mozilla.org/en-US/") + assertEquals(details.active, true) + assertEquals(details.cookieStoreId, "1") + assertEquals(tabsExtension!!.id, source.id) + tabsCreateResult.complete(null) + return GeckoResult.fromValue(null) + } + } + + tabsExtension = sessionRule.waitForResult(controller.installBuiltIn(TABS_CREATE_2_BACKGROUND)) + tabsExtension.setTabDelegate(tabDelegate) + sessionRule.waitForResult(tabsCreateResult) + + sessionRule.waitForResult(controller.uninstall(tabsExtension)) + } + + // This test + // - Create and assign WebExtension TabDelegate to handle creation and closing of tabs + // - Registers a WebExtension + // - Extension requests creation of new tab + // - TabDelegate handles creation of new tab + // - Extension requests removal of newly created tab + // - TabDelegate handles closing of newly created tab + // - Verify that close request came from right extension and targeted session + @Test + fun testBrowserTabsCreateBrowserTabsRemove() { + val onCloseRequestResult = GeckoResult<Void>() + val tabsExtension = sessionRule.waitForResult( + controller.installBuiltIn(TABS_CREATE_REMOVE_BACKGROUND), + ) + + tabsExtension.tabDelegate = object : WebExtension.TabDelegate { + override fun onNewTab(source: WebExtension, details: WebExtension.CreateTabDetails): GeckoResult<GeckoSession> { + val extensionCreatedSession = sessionRule.createClosedSession(mainSession.settings) + + extensionCreatedSession.webExtensionController.setTabDelegate( + tabsExtension, + object : WebExtension.SessionTabDelegate { + override fun onCloseTab(source: WebExtension?, session: GeckoSession): GeckoResult<AllowOrDeny> { + assertEquals(tabsExtension.id, source!!.id) + assertEquals(details.active, true) + assertNotEquals(null, extensionCreatedSession) + assertEquals(extensionCreatedSession, session) + onCloseRequestResult.complete(null) + return GeckoResult.allow() + } + }, + ) + + return GeckoResult.fromValue(extensionCreatedSession) + } + } + + sessionRule.waitForResult(onCloseRequestResult) + sessionRule.waitForResult(controller.uninstall(tabsExtension)) + } + + // This test + // - Create and assign WebExtension TabDelegate to handle creation and closing of tabs + // - Create and opens a new GeckoSession + // - Set the main session as active tab + // - Registers a WebExtension + // - Extension listens for activated tab changes + // - Set the main session as inactive tab + // - Set the newly created GeckoSession as active tab + // - Extension requests removal of newly created tab if tabs.query({active: true}) + // contains only the newly activated tab + // - TabDelegate handles closing of newly created tab + // - Verify that close request came from right extension and targeted session + @Test + fun testSetTabActive() { + val onCloseRequestResult = GeckoResult<Void>() + val tabsExtension = sessionRule.waitForResult( + controller.installBuiltIn(TABS_ACTIVATE_REMOVE_BACKGROUND), + ) + val newTabSession = sessionRule.createOpenSession(mainSession.settings) + + sessionRule.addExternalDelegateUntilTestEnd( + WebExtension.SessionTabDelegate::class, + { delegate -> newTabSession.webExtensionController.setTabDelegate(tabsExtension, delegate) }, + { newTabSession.webExtensionController.setTabDelegate(tabsExtension, null) }, + object : WebExtension.SessionTabDelegate { + + override fun onCloseTab(source: WebExtension?, session: GeckoSession): GeckoResult<AllowOrDeny> { + assertEquals(tabsExtension, source) + assertEquals(newTabSession, session) + onCloseRequestResult.complete(null) + return GeckoResult.allow() + } + }, + ) + + controller.setTabActive(mainSession, false) + controller.setTabActive(newTabSession, true) + + sessionRule.waitForResult(onCloseRequestResult) + sessionRule.waitForResult(controller.uninstall(tabsExtension)) + } + + private fun browsingDataMessage( + port: WebExtension.Port, + type: String, + since: Long? = null, + ): GeckoResult<JSONObject> { + val message = JSONObject( + "{" + + "\"type\": \"$type\"" + + "}", + ) + if (since != null) { + message.put("since", since) + } + return browsingDataCall(port, message) + } + + private fun browsingDataCall( + port: WebExtension.Port, + json: JSONObject, + ): GeckoResult<JSONObject> { + val uuid = UUID.randomUUID().toString() + json.put("uuid", uuid) + port.postMessage(json) + + val response = GeckoResult<JSONObject>() + port.setDelegate(object : WebExtension.PortDelegate { + override fun onPortMessage(message: Any, port: WebExtension.Port) { + assertThat( + "Response ID Matches.", + (message as JSONObject).getString("uuid"), + equalTo(uuid), + ) + response.complete(message) + } + }) + return response + } + + @Test + fun testBrowsingDataDelegateBuiltIn() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + + val extension = sessionRule.waitForResult( + controller.installBuiltIn(BROWSING_DATA), + ) + + val portResult = GeckoResult<WebExtension.Port>() + extension.setMessageDelegate( + object : WebExtension.MessageDelegate { + override fun onConnect(port: WebExtension.Port) { + portResult.complete(port) + } + }, + "browser", + ) + + val TEST_SINCE_VALUE = 59294 + + sessionRule.addExternalDelegateUntilTestEnd( + WebExtension.BrowsingDataDelegate::class, + { delegate -> extension.browsingDataDelegate = delegate }, + { extension.browsingDataDelegate = null }, + object : WebExtension.BrowsingDataDelegate { + override fun onGetSettings(): GeckoResult<WebExtension.BrowsingDataDelegate.Settings>? { + return GeckoResult.fromValue( + WebExtension.BrowsingDataDelegate.Settings( + TEST_SINCE_VALUE, + CACHE or COOKIES or DOWNLOADS or HISTORY or LOCAL_STORAGE, + CACHE or COOKIES or HISTORY, + ), + ) + } + }, + ) + + val port = sessionRule.waitForResult(portResult) + + // Test browsingData.removeDownloads + sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate { + @AssertCalled + override fun onClearDownloads(sinceUnixTimestamp: Long): GeckoResult<Void>? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(1234L), + ) + return null + } + }) + sessionRule.waitForResult(browsingDataMessage(port, "clear-downloads", 1234)) + + // Test browsingData.removeFormData + sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate { + @AssertCalled + override fun onClearFormData(sinceUnixTimestamp: Long): GeckoResult<Void>? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(1234L), + ) + return null + } + }) + sessionRule.waitForResult(browsingDataMessage(port, "clear-form-data", 1234)) + + // Test browsingData.removeHistory + sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate { + @AssertCalled + override fun onClearHistory(sinceUnixTimestamp: Long): GeckoResult<Void>? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(1234L), + ) + return null + } + }) + sessionRule.waitForResult(browsingDataMessage(port, "clear-history", 1234)) + + // Test browsingData.removePasswords + sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate { + @AssertCalled + override fun onClearPasswords(sinceUnixTimestamp: Long): GeckoResult<Void>? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(1234L), + ) + return null + } + }) + sessionRule.waitForResult(browsingDataMessage(port, "clear-passwords", 1234)) + + // Test browsingData.remove({ indexedDB: true, localStorage: true, passwords: true }) + sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate { + @AssertCalled + override fun onClearPasswords(sinceUnixTimestamp: Long): GeckoResult<Void>? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(0L), + ) + return null + } + }) + var response = sessionRule.waitForResult( + browsingDataCall( + port, + JSONObject( + "{" + + "\"type\": \"clear\"," + + "\"removalOptions\": {}," + + "\"dataTypes\": {\"indexedDB\": true, \"localStorage\": true, \"passwords\": true}" + + "}", + ), + ), + ) + assertThat( + "browsingData.remove should succeed", + response.getString("type"), + equalTo("response"), + ) + + // Test browsingData.remove({ indexedDB: true, history: true, passwords: true }) + sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate { + @AssertCalled + override fun onClearPasswords(sinceUnixTimestamp: Long): GeckoResult<Void>? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(0L), + ) + return null + } + + @AssertCalled + override fun onClearHistory(sinceUnixTimestamp: Long): GeckoResult<Void>? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(0L), + ) + return null + } + }) + response = sessionRule.waitForResult( + browsingDataCall( + port, + JSONObject( + "{" + + "\"type\": \"clear\"," + + "\"removalOptions\": {}," + + "\"dataTypes\": {\"indexedDB\": true, \"history\": true, \"passwords\": true}" + + "}", + ), + ), + ) + assertThat( + "browsingData.remove should succeed", + response.getString("type"), + equalTo("response"), + ) + + // Test browsingData.remove({ indexedDB: true, history: true, passwords: true }) + // with failure + sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate { + @AssertCalled + override fun onClearPasswords(sinceUnixTimestamp: Long): GeckoResult<Void>? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(0L), + ) + return null + } + + @AssertCalled + override fun onClearHistory(sinceUnixTimestamp: Long): GeckoResult<Void>? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(0L), + ) + return GeckoResult.fromException(RuntimeException("Not authorized.")) + } + }) + response = sessionRule.waitForResult( + browsingDataCall( + port, + JSONObject( + "{" + + "\"type\": \"clear\"," + + "\"removalOptions\": {}," + + "\"dataTypes\": {\"indexedDB\": true, \"history\": true, \"passwords\": true}" + + "}", + ), + ), + ) + assertThat( + "browsingData.remove returns expected error.", + response.getString("error"), + equalTo("Not authorized."), + ) + + // Test browsingData.remove({ indexedDB: true, history: true, passwords: true }) + // with multiple failures + sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate { + @AssertCalled + override fun onClearPasswords(sinceUnixTimestamp: Long): GeckoResult<Void>? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(0L), + ) + return GeckoResult.fromException(RuntimeException("Not authorized passwords.")) + } + + @AssertCalled + override fun onClearHistory(sinceUnixTimestamp: Long): GeckoResult<Void>? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(0L), + ) + return GeckoResult.fromException(RuntimeException("Not authorized history.")) + } + }) + response = sessionRule.waitForResult( + browsingDataCall( + port, + JSONObject( + "{" + + "\"type\": \"clear\"," + + "\"removalOptions\": {}," + + "\"dataTypes\": {\"indexedDB\": true, \"history\": true, \"passwords\": true}" + + "}", + ), + ), + ) + val error = response.getString("error") + assertThat( + "browsingData.remove returns expected error.", + error == "Not authorized passwords." || error == "Not authorized history.", + equalTo(true), + ) + + // Test browsingData.settings() + response = sessionRule.waitForResult( + browsingDataMessage(port, "get-settings"), + ) + + val settings = response.getJSONObject("result") + val dataToRemove = settings.getJSONObject("dataToRemove") + val options = settings.getJSONObject("options") + + assertThat( + "Since should be correct", + options.getInt("since"), + equalTo(TEST_SINCE_VALUE), + ) + for (key in listOf("cache", "cookies", "history")) { + assertThat( + "Data to remove should be correct", + dataToRemove.getBoolean(key), + equalTo(true), + ) + } + for (key in listOf("downloads", "localStorage")) { + assertThat( + "Data to remove should be correct", + dataToRemove.getBoolean(key), + equalTo(false), + ) + } + + val dataRemovalPermitted = settings.getJSONObject("dataRemovalPermitted") + for (key in listOf("cache", "cookies", "downloads", "history", "localStorage")) { + assertThat( + "Data removal permitted should be correct", + dataRemovalPermitted.getBoolean(key), + equalTo(true), + ) + } + + // Test browsingData.settings() with no delegate + sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate { + override fun onGetSettings(): GeckoResult<WebExtension.BrowsingDataDelegate.Settings>? { + return null + } + }) + response = sessionRule.waitForResult( + browsingDataMessage(port, "get-settings"), + ) + assertThat( + "browsingData.settings returns expected error.", + response.getString("error"), + equalTo("browsingData.settings is not supported"), + ) + + sessionRule.waitForResult(controller.uninstall(extension)) + } + + @Test + fun testBrowsingDataDelegate() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + return GeckoResult.allow() + } + }) + + val extension = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/browsing-data.xpi"), + ) + + val accumulator = mutableListOf<String>() + val result = GeckoResult<List<String>>() + + extension.browsingDataDelegate = object : WebExtension.BrowsingDataDelegate { + fun register(type: String, timestamp: Long) { + accumulator.add("$type $timestamp") + if (accumulator.size >= 5) { + result.complete(accumulator) + } + } + + override fun onClearDownloads(sinceUnixTimestamp: Long): GeckoResult<Void> { + register("downloads", sinceUnixTimestamp) + return GeckoResult.fromValue(null) + } + + override fun onClearFormData(sinceUnixTimestamp: Long): GeckoResult<Void> { + register("formData", sinceUnixTimestamp) + return GeckoResult.fromValue(null) + } + + override fun onClearHistory(sinceUnixTimestamp: Long): GeckoResult<Void> { + register("history", sinceUnixTimestamp) + return GeckoResult.fromValue(null) + } + + override fun onClearPasswords(sinceUnixTimestamp: Long): GeckoResult<Void> { + register("passwords", sinceUnixTimestamp) + return GeckoResult.fromValue(null) + } + } + + val actual = sessionRule.waitForResult(result) + assertThat( + "Delegate methods get called in the right order", + actual, + equalTo( + listOf( + "downloads 10001", + "formData 10002", + "history 10003", + "passwords 10004", + "downloads 10005", + ), + ), + ) + + sessionRule.waitForResult(controller.uninstall(extension)) + } + + // Same as testSetTabActive when the extension is not allowed in private browsing + @Test + fun testSetTabActiveNotAllowedInPrivateBrowsing() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + + val onCloseRequestResult = GeckoResult<Void>() + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + return GeckoResult.allow() + } + }) + val tabsExtension = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/tabs-activate-remove.xpi"), + ) + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + return GeckoResult.allow() + } + }) + var tabsExtensionPB = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/tabs-activate-remove-2.xpi"), + ) + + tabsExtensionPB = sessionRule.waitForResult( + controller.setAllowedInPrivateBrowsing(tabsExtensionPB, true), + ) + + val newTabSession = sessionRule.createOpenSession(mainSession.settings) + + val newPrivateSession = sessionRule.createOpenSession( + GeckoSessionSettings.Builder().usePrivateMode(true).build(), + ) + + val privateBrowsingNewTabSession = GeckoResult<Void>() + + class TabDelegate( + val result: GeckoResult<Void>, + val extension: WebExtension, + val expectedSession: GeckoSession, + ) : + WebExtension.SessionTabDelegate { + override fun onCloseTab( + source: WebExtension?, + session: GeckoSession, + ): GeckoResult<AllowOrDeny> { + assertEquals(extension.id, source!!.id) + assertEquals(expectedSession, session) + result.complete(null) + return GeckoResult.allow() + } + } + + newTabSession.webExtensionController.setTabDelegate( + tabsExtensionPB, + TabDelegate(privateBrowsingNewTabSession, tabsExtensionPB, newTabSession), + ) + + newTabSession.webExtensionController.setTabDelegate( + tabsExtension, + TabDelegate(onCloseRequestResult, tabsExtension, newTabSession), + ) + + val privateBrowsingPrivateSession = GeckoResult<Void>() + + newPrivateSession.webExtensionController.setTabDelegate( + tabsExtensionPB, + TabDelegate(privateBrowsingPrivateSession, tabsExtensionPB, newPrivateSession), + ) + + // tabsExtension is not allowed in private browsing and shouldn't get this event + newPrivateSession.webExtensionController.setTabDelegate( + tabsExtension, + object : WebExtension.SessionTabDelegate { + override fun onCloseTab( + source: WebExtension?, + session: GeckoSession, + ): GeckoResult<AllowOrDeny> { + privateBrowsingPrivateSession.completeExceptionally( + RuntimeException("Should never happen"), + ) + return GeckoResult.allow() + } + }, + ) + + controller.setTabActive(mainSession, false) + controller.setTabActive(newPrivateSession, true) + + sessionRule.waitForResult(privateBrowsingPrivateSession) + + controller.setTabActive(newPrivateSession, false) + controller.setTabActive(newTabSession, true) + + sessionRule.waitForResult(onCloseRequestResult) + sessionRule.waitForResult(privateBrowsingNewTabSession) + + sessionRule.waitForResult( + sessionRule.runtime.webExtensionController.uninstall(tabsExtension), + ) + sessionRule.waitForResult( + sessionRule.runtime.webExtensionController.uninstall(tabsExtensionPB), + ) + + newTabSession.close() + newPrivateSession.close() + } + + // Verifies that the following messages are received from an extension page loaded in the session + // - HELLO_FROM_PAGE_1 from nativeApp browser1 + // - HELLO_FROM_PAGE_2 from nativeApp browser2 + // - connection request from browser1 + // - HELLO_FROM_PORT from the port opened at the above step + private fun testExtensionMessages(extension: WebExtension, session: GeckoSession) { + val messageResult2 = GeckoResult<String>() + session.webExtensionController.setMessageDelegate( + extension, + object : WebExtension.MessageDelegate { + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender, + ): GeckoResult<Any>? { + messageResult2.complete(message as String) + return null + } + }, + "browser2", + ) + + val message2 = sessionRule.waitForResult(messageResult2) + assertThat( + "Message is received correctly", + message2, + equalTo("HELLO_FROM_PAGE_2"), + ) + + val messageResult1 = GeckoResult<String>() + val portResult = GeckoResult<WebExtension.Port>() + session.webExtensionController.setMessageDelegate( + extension, + object : WebExtension.MessageDelegate { + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender, + ): GeckoResult<Any>? { + messageResult1.complete(message as String) + return null + } + + override fun onConnect(port: WebExtension.Port) { + portResult.complete(port) + } + }, + "browser1", + ) + + val message1 = sessionRule.waitForResult(messageResult1) + assertThat( + "Message is received correctly", + message1, + equalTo("HELLO_FROM_PAGE_1"), + ) + + val port = sessionRule.waitForResult(portResult) + val portMessageResult = GeckoResult<String>() + port.setDelegate(object : WebExtension.PortDelegate { + override fun onPortMessage(message: Any, port: WebExtension.Port) { + portMessageResult.complete(message as String) + } + }) + + val portMessage = sessionRule.waitForResult(portMessageResult) + assertThat( + "Message is received correctly", + portMessage, + equalTo("HELLO_FROM_PORT"), + ) + } + + // This test: + // - loads an extension that tries to send some messages when loading tab.html + // - verifies that the messages are received when loading the tab normally + // - verifies that the messages are received when restoring the tab in a fresh session + @Test + fun testRestoringExtensionPagePreservesMessages() { + // TODO: Bug 1648158 + assumeThat(sessionRule.env.isFission, equalTo(false)) + + val extension = sessionRule.waitForResult( + controller.installBuiltIn(EXTENSION_PAGE_RESTORE), + ) + + mainSession.loadUri("${extension.metaData.baseUrl}tab.html") + sessionRule.waitForPageStop() + + var savedState: GeckoSession.SessionState? = null + sessionRule.waitUntilCalled(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onSessionStateChange(session: GeckoSession, state: GeckoSession.SessionState) { + savedState = state + } + }) + + // Test that messages are received in the main session + testExtensionMessages(extension, mainSession) + + val newSession = sessionRule.createOpenSession() + newSession.restoreState(savedState!!) + newSession.waitForPageStop() + + // Test that messages are received in a restored state + testExtensionMessages(extension, newSession) + + sessionRule.waitForResult(controller.uninstall(extension)) + } + + // This test + // - Create and assign WebExtension TabDelegate to handle closing of tabs + // - Create new GeckoSession for WebExtension to close + // - Load url that will allow extension to identify the tab + // - Registers a WebExtension + // - Extension finds the tab by url and removes it + // - TabDelegate handles closing of the tab + // - Verify that request targets previously created GeckoSession + @Test + fun testBrowserTabsRemove() { + val onCloseRequestResult = GeckoResult<Void>() + val existingSession = sessionRule.createOpenSession() + + existingSession.loadTestPath("$HELLO_HTML_PATH?tabToClose") + existingSession.waitForPageStop() + + val tabsExtension = sessionRule.waitForResult( + controller.installBuiltIn(TABS_REMOVE_BACKGROUND), + ) + + sessionRule.addExternalDelegateUntilTestEnd( + WebExtension.SessionTabDelegate::class, + { delegate -> existingSession.webExtensionController.setTabDelegate(tabsExtension, delegate) }, + { existingSession.webExtensionController.setTabDelegate(tabsExtension, null) }, + object : WebExtension.SessionTabDelegate { + override fun onCloseTab(source: WebExtension?, session: GeckoSession): GeckoResult<AllowOrDeny> { + assertEquals(existingSession, session) + onCloseRequestResult.complete(null) + return GeckoResult.allow() + } + }, + ) + + sessionRule.waitForResult(onCloseRequestResult) + sessionRule.waitForResult(controller.uninstall(tabsExtension)) + } + + private fun installWebExtension( + background: Boolean, + messageDelegate: WebExtension.MessageDelegate, + ): WebExtension { + val webExtension: WebExtension + + if (background) { + webExtension = sessionRule.waitForResult( + controller.installBuiltIn(MESSAGING_BACKGROUND), + ) + webExtension.setMessageDelegate(messageDelegate, "browser") + } else { + webExtension = sessionRule.waitForResult( + controller.installBuiltIn(MESSAGING_CONTENT), + ) + mainSession.webExtensionController + .setMessageDelegate(webExtension, messageDelegate, "browser") + } + + return webExtension + } + + @Test + fun contentMessaging() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + testOnMessage(false) + } + + @Test + fun backgroundMessaging() { + testOnMessage(true) + } + + // This test + // - installs a web extension + // - waits for the web extension to connect to the browser + // - on connect it will start listening on the port for a message + // - When the message is received it sends a message in response and waits for another message + // - When the second message is received it verifies it contains the expected value + // + // When `background == true` the test will be run using background messaging, otherwise the + // test will use content script messaging. + private fun testPortMessage(background: Boolean) { + val result = GeckoResult<Void>() + val prefix = if (background) "testBackground" else "testContent" + + val portDelegate = object : WebExtension.PortDelegate { + var awaitingResponse = false + + override fun onPortMessage(message: Any, port: WebExtension.Port) { + assertEquals(port.name, "browser") + + if (!awaitingResponse) { + assertThat( + "We should receive a message from the WebExtension", + message as String, + equalTo("${prefix}PortMessage"), + ) + port.postMessage(JSONObject("{\"message\": \"${prefix}PortMessageResponse\"}")) + awaitingResponse = true + } else { + assertThat( + "The background script should receive our message and respond", + message as String, + equalTo("response: ${prefix}PortMessageResponse"), + ) + result.complete(null) + } + } + + override fun onDisconnect(port: WebExtension.Port) { + // ignored + } + } + + val messageDelegate = object : WebExtension.MessageDelegate { + override fun onConnect(port: WebExtension.Port) { + checkSender(port.name, port.sender, background) + + assertEquals(port.name, "browser") + + port.setDelegate(portDelegate) + } + + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender, + ): GeckoResult<Any>? { + // Ignored for this test + return null + } + } + + val messaging = installWebExtension(background, messageDelegate) + sessionRule.waitForResult(result) + sessionRule.waitForResult(controller.uninstall(messaging)) + } + + @Test + fun contentPortMessaging() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + testPortMessage(false) + } + + @Test + fun backgroundPortMessaging() { + testPortMessage(true) + } + + // This test + // - Registers a web extension + // - Awaits for the web extension to connect to the browser + // - When connected, it triggers a disconnection from the other side and verifies that + // the browser is notified of the port being disconnected. + // + // When `background == true` the test will be run using background messaging, otherwise the + // test will use content script messaging. + // + // When `refresh == true` the disconnection will be triggered by refreshing the page, otherwise + // it will be triggered by sending a message to the web extension. + private fun testPortDisconnect(background: Boolean, refresh: Boolean) { + val result = GeckoResult<Void>() + + var messaging: WebExtension? = null + var messagingPort: WebExtension.Port? = null + + val portDelegate = object : WebExtension.PortDelegate { + override fun onPortMessage( + message: Any, + port: WebExtension.Port, + ) { + assertEquals(port, messagingPort) + } + + override fun onDisconnect(port: WebExtension.Port) { + assertEquals(messaging!!.id, port.sender.webExtension.id) + assertEquals(port, messagingPort) + // We successfully received a disconnection + result.complete(null) + } + } + + val messageDelegate = object : WebExtension.MessageDelegate { + override fun onConnect(port: WebExtension.Port) { + assertEquals(messaging!!.id, port.sender.webExtension.id) + checkSender(port.name, port.sender, background) + + assertEquals(port.name, "browser") + messagingPort = port + port.setDelegate(portDelegate) + + if (refresh) { + // Refreshing the page should disconnect the port + mainSession.reload() + } else { + // Let's ask the web extension to disconnect this port + val message = JSONObject() + message.put("action", "disconnect") + + port.postMessage(message) + } + } + + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender, + ): GeckoResult<Any>? { + assertEquals(messaging!!.id, sender.webExtension.id) + + // Ignored for this test + return null + } + } + + messaging = installWebExtension(background, messageDelegate) + sessionRule.waitForResult(result) + sessionRule.waitForResult(controller.uninstall(messaging)) + } + + @Test + fun contentPortDisconnect() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + testPortDisconnect(background = false, refresh = false) + } + + @Test + fun backgroundPortDisconnect() { + testPortDisconnect(background = true, refresh = false) + } + + @Test + fun contentPortDisconnectAfterRefresh() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + testPortDisconnect(background = false, refresh = true) + } + + fun checkSender(nativeApp: String, sender: WebExtension.MessageSender, background: Boolean) { + assertEquals("nativeApp should always be 'browser'", nativeApp, "browser") + + if (background) { + // For background scripts we only want messages from the extension, this should never + // happen and it's a bug if we get here. + assertEquals( + "Called from content script with background-only delegate.", + sender.environmentType, + WebExtension.MessageSender.ENV_TYPE_EXTENSION, + ) + assertTrue( + "Unexpected sender url", + sender.url.endsWith("/_generated_background_page.html"), + ) + } else { + assertEquals( + "Called from background script, expecting only content scripts", + sender.environmentType, + WebExtension.MessageSender.ENV_TYPE_CONTENT_SCRIPT, + ) + assertTrue("Expecting only top level senders.", sender.isTopLevel) + assertEquals("Unexpected sender url", sender.url, "https://example.com/") + } + } + + // This test + // - Register a web extension and waits for connections + // - When connected it disconnects the port from the app side + // - Awaits for a message from the web extension confirming the web extension was notified of + // port being closed. + // + // When `background == true` the test will be run using background messaging, otherwise the + // test will use content script messaging. + private fun testPortDisconnectFromApp(background: Boolean) { + val result = GeckoResult<Void>() + + var messaging: WebExtension? = null + + val messageDelegate = object : WebExtension.MessageDelegate { + override fun onConnect(port: WebExtension.Port) { + assertEquals(messaging!!.id, port.sender.webExtension.id) + checkSender(port.name, port.sender, background) + + port.disconnect() + } + + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender, + ): GeckoResult<Any>? { + assertEquals(messaging!!.id, sender.webExtension.id) + checkSender(nativeApp, sender, background) + + if (message is JSONObject) { + if (message.getString("type") == "portDisconnected") { + result.complete(null) + } + } + + return null + } + } + + messaging = installWebExtension(background, messageDelegate) + sessionRule.waitForResult(result) + sessionRule.waitForResult(controller.uninstall(messaging)) + } + + @Test + fun contentPortDisconnectFromApp() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + testPortDisconnectFromApp(false) + } + + @Test + fun backgroundPortDisconnectFromApp() { + testPortDisconnectFromApp(true) + } + + // This test checks that scripts running in a iframe have the `isTopLevel` property set to false. + private fun testIframeTopLevel() { + val portTopLevel = GeckoResult<Void>() + val portIframe = GeckoResult<Void>() + val messageTopLevel = GeckoResult<Void>() + val messageIframe = GeckoResult<Void>() + + var messaging: WebExtension? = null + + val messageDelegate = object : WebExtension.MessageDelegate { + override fun onConnect(port: WebExtension.Port) { + assertEquals(messaging!!.id, port.sender.webExtension.id) + assertEquals( + WebExtension.MessageSender.ENV_TYPE_CONTENT_SCRIPT, + port.sender.environmentType, + ) + when (port.sender.url) { + "$TEST_ENDPOINT$HELLO_IFRAME_HTML_PATH" -> { + assertTrue(port.sender.isTopLevel) + portTopLevel.complete(null) + } + "$TEST_ENDPOINT$HELLO_HTML_PATH" -> { + assertFalse(port.sender.isTopLevel) + portIframe.complete(null) + } + else -> // We shouldn't get other messages + fail() + } + + port.disconnect() + } + + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender, + ): GeckoResult<Any>? { + assertEquals(messaging!!.id, sender.webExtension.id) + assertEquals( + WebExtension.MessageSender.ENV_TYPE_CONTENT_SCRIPT, + sender.environmentType, + ) + when (sender.url) { + "$TEST_ENDPOINT$HELLO_IFRAME_HTML_PATH" -> { + assertTrue(sender.isTopLevel) + messageTopLevel.complete(null) + } + "$TEST_ENDPOINT$HELLO_HTML_PATH" -> { + assertFalse(sender.isTopLevel) + messageIframe.complete(null) + } + else -> // We shouldn't get other messages + fail() + } + + return null + } + } + + messaging = sessionRule.waitForResult( + controller.installBuiltIn( + "resource://android/assets/web_extensions/messaging-iframe/", + ), + ) + mainSession.webExtensionController + .setMessageDelegate(messaging, messageDelegate, "browser") + sessionRule.waitForResult(portTopLevel) + sessionRule.waitForResult(portIframe) + sessionRule.waitForResult(messageTopLevel) + sessionRule.waitForResult(messageIframe) + sessionRule.waitForResult(controller.uninstall(messaging)) + } + + @Test + fun iframeTopLevel() { + mainSession.loadTestPath(HELLO_IFRAME_HTML_PATH) + sessionRule.waitForPageStop() + testIframeTopLevel() + } + + @Test + fun redirectToExtensionResource() { + val result = GeckoResult<String>() + val messageDelegate = object : WebExtension.MessageDelegate { + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender, + ): GeckoResult<Any>? { + assertEquals(message, "setupReadyStartTest") + result.complete(null) + return null + } + } + + val extension = sessionRule.waitForResult( + controller.installBuiltIn( + "resource://android/assets/web_extensions/redirect-to-android-resource/", + ), + ) + + extension.setMessageDelegate(messageDelegate, "browser") + sessionRule.waitForResult(result) + + // Extension has set up some webRequest listeners to redirect requests. + // Open the test page and verify that the extension has redirected the + // scripts as expected. + mainSession.loadTestPath(TRACKERS_PATH) + sessionRule.waitForPageStop() + + val textContent = mainSession.evaluateJS("document.body.textContent.replace(/\\s/g, '')") + assertThat( + "The extension should have rewritten the script requests and the body", + textContent as String, + equalTo("start,extension-was-here,end"), + ) + + sessionRule.waitForResult(controller.uninstall(extension)) + } + + @Test + fun loadWebExtensionPage() { + val result = GeckoResult<String>() + var extension: WebExtension? = null + + val messageDelegate = object : WebExtension.MessageDelegate { + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender, + ): GeckoResult<Any>? { + assertEquals(extension!!.id, sender.webExtension.id) + assertEquals( + WebExtension.MessageSender.ENV_TYPE_EXTENSION, + sender.environmentType, + ) + result.complete(message as String) + + return null + } + } + + extension = sessionRule.waitForResult( + controller.ensureBuiltIn( + "resource://android/assets/web_extensions/extension-page-update/", + "extension-page-update@tests.mozilla.org", + ), + ) + + val sessionController = mainSession.webExtensionController + sessionController.setMessageDelegate(extension, messageDelegate, "browser") + sessionController.setTabDelegate( + extension, + object : WebExtension.SessionTabDelegate { + override fun onUpdateTab( + extension: WebExtension, + session: GeckoSession, + details: WebExtension.UpdateTabDetails, + ): GeckoResult<AllowOrDeny> { + return GeckoResult.allow() + } + }, + ) + + mainSession.loadUri("https://example.com") + + mainSession.waitUntilCalled(object : NavigationDelegate, ProgressDelegate { + @GeckoSessionTestRule.AssertCalled(count = 1) + override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList<PermissionDelegate.ContentPermission>) { + assertThat( + "Url should load example.com first", + url, + equalTo("https://example.com/"), + ) + } + + @GeckoSessionTestRule.AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat( + "Page should load successfully.", + success, + equalTo(true), + ) + } + }) + + var page: String? = null + val pageStop = GeckoResult<Boolean>() + + mainSession.delegateUntilTestEnd(object : NavigationDelegate, ProgressDelegate { + override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList<PermissionDelegate.ContentPermission>) { + page = url + } + + override fun onPageStop(session: GeckoSession, success: Boolean) { + if (success && page != null && page!!.endsWith("/tab.html")) { + pageStop.complete(true) + } + } + }) + + // If ensureBuiltIn works correctly, this will not re-install the extension. + // We can verify that it won't reinstall because that would cause the extension page to + // close prematurely, making the test fail. + val ensure = sessionRule.waitForResult( + controller.ensureBuiltIn( + "resource://android/assets/web_extensions/extension-page-update/", + "extension-page-update@tests.mozilla.org", + ), + ) + + assertThat("ID match", ensure.id, equalTo(extension.id)) + assertThat("version match", ensure.metaData.version, equalTo(extension.metaData.version)) + + // Make sure the page loaded successfully + sessionRule.waitForResult(pageStop) + + assertThat("Url should load WebExtension page", page, endsWith("/tab.html")) + + assertThat( + "WebExtension page should have access to privileged APIs", + sessionRule.waitForResult(result), + equalTo("HELLO_FROM_PAGE"), + ) + + // Test that after uninstalling an extension, all its pages get closed + sessionRule.addExternalDelegateUntilTestEnd( + WebExtension.SessionTabDelegate::class, + { delegate -> mainSession.webExtensionController.setTabDelegate(extension, delegate) }, + { mainSession.webExtensionController.setTabDelegate(extension, null) }, + object : WebExtension.SessionTabDelegate {}, + ) + + val uninstall = controller.uninstall(extension) + + sessionRule.waitUntilCalled(object : WebExtension.SessionTabDelegate { + @AssertCalled + override fun onCloseTab( + source: WebExtension?, + session: GeckoSession, + ): GeckoResult<AllowOrDeny> { + assertEquals(extension.id, source!!.id) + assertEquals(mainSession, session) + return GeckoResult.allow() + } + }) + + sessionRule.waitForResult(uninstall) + } + + @Test + fun badUrl() { + testInstallBuiltInError("invalid url", "Could not parse uri") + } + + @Test + fun badHost() { + testInstallBuiltInError("resource://gre/", "Only resource://android") + } + + @Test + fun dontAllowRemoteUris() { + testInstallBuiltInError("https://example.com/extension/", "Only resource://android") + } + + @Test + fun badFileType() { + testInstallBuiltInError( + "resource://android/bad/location/error", + "does not point to a folder", + ) + } + + @Test + fun badLocationXpi() { + testInstallBuiltInError( + "resource://android/bad/location/error.xpi", + "does not point to a folder", + ) + } + + @Test + fun testInstallBuiltInError() { + testInstallBuiltInError( + "resource://android/bad/location/error/", + "does not contain a valid manifest", + ) + } + + private fun testInstallBuiltInError(location: String, expectedError: String) { + try { + sessionRule.waitForResult(controller.installBuiltIn(location)) + } catch (ex: Exception) { + // Let's make sure the error message contains the expected error message + assertTrue(ex.message!!.contains(expectedError)) + + return + } + + fail("The above code should throw.") + } + + // Test web extension permission.request. + @WithDisplay(width = 100, height = 100) + @Test + fun permissionRequest() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + + val extension = sessionRule.waitForResult( + controller.ensureBuiltIn( + "resource://android/assets/web_extensions/permission-request/", + "permissions@example.com", + ), + ) + + mainSession.loadUri("${extension.metaData.baseUrl}clickToRequestPermission.html") + sessionRule.waitForPageStop() + + // click triggers permissions.request + mainSession.synthesizeTap(50, 50) + + sessionRule.delegateUntilTestEnd(object : WebExtensionController.PromptDelegate { + @AssertCalled(count = 2) + override fun onOptionalPrompt(extension: WebExtension, permissions: Array<String>, origins: Array<String>): GeckoResult<AllowOrDeny> { + val expected = arrayOf("geolocation") + assertThat("Permissions should match the requested permissions", permissions, equalTo(expected)) + assertThat("Origins should match the requested origins", origins, equalTo(arrayOf("*://example.com/*"))) + return forEachCall(GeckoResult.deny(), GeckoResult.allow()) + } + }) + + var result = GeckoResult<String>() + mainSession.webExtensionController.setMessageDelegate( + extension, + object : WebExtension.MessageDelegate { + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender, + ): GeckoResult<Any>? { + result.complete(message as String) + return null + } + }, + "browser", + ) + + val message = sessionRule.waitForResult(result) + assertThat("Permission request should first be denied.", message, equalTo("false")) + + mainSession.synthesizeTap(50, 50) + result = GeckoResult<String>() + val message2 = sessionRule.waitForResult(result) + assertThat("Permission request should be accepted.", message2, equalTo("true")) + + mainSession.synthesizeTap(50, 50) + result = GeckoResult<String>() + val message3 = sessionRule.waitForResult(result) + assertThat("Permission request should already be accepted.", message3, equalTo("true")) + + sessionRule.waitForResult(controller.uninstall(extension)) + } + + // Test the basic update extension flow with no new permissions. + @Test + fun update() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + // First let's check that the color of the border is empty before loading + // the WebExtension + assertBodyBorderEqualTo("") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + assertEquals(extension.metaData.version, "1.0") + + return GeckoResult.allow() + } + }) + + val update1 = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/update-1.xpi"), + ) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was applied by checking the border color + assertBodyBorderEqualTo("red") + + val update2 = sessionRule.waitForResult(controller.update(update1)) + assertEquals(update2.metaData.version, "2.0") + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that updated extension changed the border color. + assertBodyBorderEqualTo("blue") + + // Uninstall WebExtension and check again + sessionRule.waitForResult(controller.uninstall(update2)) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was not applied after being uninstalled + assertBodyBorderEqualTo("") + } + + // Test extension updating when the new extension has different permissions. + @Test + fun updateWithPerms() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + // First let's check that the color of the border is empty before loading + // the WebExtension + assertBodyBorderEqualTo("") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + assertEquals(extension.metaData.version, "1.0") + + return GeckoResult.allow() + } + }) + + val update1 = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/update-with-perms-1.xpi"), + ) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was applied by checking the border color + assertBodyBorderEqualTo("red") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onUpdatePrompt( + currentlyInstalled: WebExtension, + updatedExtension: WebExtension, + newPermissions: Array<String>, + newOrigins: Array<String>, + ): GeckoResult<AllowOrDeny> { + assertEquals(currentlyInstalled.metaData.version, "1.0") + assertEquals(updatedExtension.metaData.version, "2.0") + assertEquals(newPermissions.size, 1) + assertEquals(newPermissions[0], "tabs") + return GeckoResult.allow() + } + }) + + val update2 = sessionRule.waitForResult(controller.update(update1)) + assertEquals(update2.metaData.version, "2.0") + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that updated extension changed the border color. + assertBodyBorderEqualTo("blue") + + // Uninstall WebExtension and check again + sessionRule.waitForResult(controller.uninstall(update2)) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was not applied after being uninstalled + assertBodyBorderEqualTo("") + } + + // Ensure update extension works as expected when there is no update available. + @Test + fun updateNotAvailable() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + // First let's check that the color of the border is empty before loading + // the WebExtension + assertBodyBorderEqualTo("") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + assertEquals(extension.metaData.version, "2.0") + + return GeckoResult.allow() + } + }) + + val update1 = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/update-2.xpi"), + ) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was applied by checking the border color + assertBodyBorderEqualTo("blue") + + val update2 = sessionRule.waitForResult(controller.update(update1)) + assertNull(update2) + + // Uninstall WebExtension and check again + sessionRule.waitForResult(controller.uninstall(update1)) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was not applied after being uninstalled + assertBodyBorderEqualTo("") + } + + // Test denying an extension update. + @Test + fun updateDenyPerms() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + // First let's check that the color of the border is empty before loading + // the WebExtension + assertBodyBorderEqualTo("") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + assertEquals(extension.metaData.version, "1.0") + + return GeckoResult.allow() + } + }) + + val update1 = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/update-with-perms-1.xpi"), + ) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was applied by checking the border color + assertBodyBorderEqualTo("red") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onUpdatePrompt( + currentlyInstalled: WebExtension, + updatedExtension: WebExtension, + newPermissions: Array<String>, + newOrigins: Array<String>, + ): GeckoResult<AllowOrDeny> { + assertEquals(currentlyInstalled.metaData.version, "1.0") + assertEquals(updatedExtension.metaData.version, "2.0") + return GeckoResult.deny() + } + }) + + sessionRule.waitForResult( + controller.update(update1).accept({ + // We should not be able to update the extension. + assertTrue(false) + }, { exception -> + assertTrue(exception is WebExtension.InstallException) + val installException = exception as WebExtension.InstallException + assertEquals(installException.code, WebExtension.InstallException.ErrorCodes.ERROR_USER_CANCELED) + }), + ) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that updated extension changed the border color. + assertBodyBorderEqualTo("red") + + // Uninstall WebExtension and check again + sessionRule.waitForResult(controller.uninstall(update1)) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was not applied after being uninstalled + assertBodyBorderEqualTo("") + } + + @Test(expected = CancellationException::class) + fun cancelInstall() { + val install = controller.install("$TEST_ENDPOINT/stall/test.xpi") + val cancel = sessionRule.waitForResult(install.cancel()) + assertTrue(cancel) + + sessionRule.waitForResult(install) + } + + @Test + fun cancelInstallFailsAfterInstalled() { + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + return GeckoResult.allow() + } + }) + + var install = controller.install("resource://android/assets/web_extensions/borderify.xpi") + val borderify = sessionRule.waitForResult(install) + + val cancel = sessionRule.waitForResult(install.cancel()) + assertFalse(cancel) + + sessionRule.waitForResult(controller.uninstall(borderify)) + } + + @Test + fun updatePostpone() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + "extensions.webextensions.warnings-as-errors" to false, + ), + ) + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + // First let's check that the color of the border is empty before loading + // the WebExtension + assertBodyBorderEqualTo("") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + assertEquals(extension.metaData.version, "1.0") + return GeckoResult.allow() + } + }) + + val update1 = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/update-postpone-1.xpi"), + ) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was applied by checking the border color + assertBodyBorderEqualTo("red") + + sessionRule.waitForResult( + controller.update(update1).accept({ + // We should not be able to update the extension. + assertTrue(false) + }, { exception -> + assertTrue(exception is WebExtension.InstallException) + val installException = exception as WebExtension.InstallException + assertEquals(installException.code, WebExtension.InstallException.ErrorCodes.ERROR_POSTPONED) + }), + ) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension is still the first extension. + assertBodyBorderEqualTo("red") + + sessionRule.waitForResult(controller.uninstall(update1)) + } + + /* + This function installs a web extension, disables it, updates it and uninstalls it + + @param source: Int - represents a logical type; can be EnableSource.APP or EnableSource.USER + */ + private fun testUpdatingExtensionDisabledBy(source: Int) { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + return GeckoResult.allow() + } + }) + + sessionRule.addExternalDelegateUntilTestEnd( + WebExtensionController.AddonManagerDelegate::class, + { delegate -> controller.setAddonManagerDelegate(delegate) }, + { controller.setAddonManagerDelegate(null) }, + object : WebExtensionController.AddonManagerDelegate { + @AssertCalled(count = 0) + override fun onEnabling(extension: WebExtension) {} + + @AssertCalled(count = 0) + override fun onEnabled(extension: WebExtension) {} + + @AssertCalled(count = 1) + override fun onDisabling(extension: WebExtension) {} + + @AssertCalled(count = 1) + override fun onDisabled(extension: WebExtension) {} + + @AssertCalled(count = 1) + override fun onUninstalling(extension: WebExtension) {} + + @AssertCalled(count = 1) + override fun onUninstalled(extension: WebExtension) {} + + // We expect onInstalling/onInstalled to be invoked twice + // because we first install the extension and then we update + // it, which results in a second install. + @AssertCalled(count = 2) + override fun onInstalling(extension: WebExtension) {} + + @AssertCalled(count = 2) + override fun onInstalled(extension: WebExtension) {} + }, + ) + + val webExtension = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/update-1.xpi"), + ) + + mainSession.reload() + sessionRule.waitForPageStop() + + val disabledWebExtension = sessionRule.waitForResult(controller.disable(webExtension, source)) + + when (source) { + EnableSource.APP -> checkDisabledState(disabledWebExtension, appDisabled = true) + EnableSource.USER -> checkDisabledState(disabledWebExtension, userDisabled = true) + } + + val updatedWebExtension = sessionRule.waitForResult(controller.update(disabledWebExtension)) + + mainSession.reload() + sessionRule.waitForPageStop() + + sessionRule.waitForResult(controller.uninstall(updatedWebExtension)) + } + + @Test + fun updateDisabledByUser() { + testUpdatingExtensionDisabledBy(EnableSource.USER) + } + + @Test + fun updateDisabledByApp() { + testUpdatingExtensionDisabledBy(EnableSource.APP) + } + + // This test + // - Listen for a newTab request from a web extension + // - Registers a web extension + // - Waits for onNewTab request + // - Verify that request came from right extension + @Test + fun testBrowserRuntimeOpenOptionsPageInNewTab() { + val tabsCreateResult = GeckoResult<Void>() + var optionsExtension: WebExtension? = null + val tabDelegate = object : WebExtension.TabDelegate { + @AssertCalled(count = 1) + override fun onNewTab( + source: WebExtension, + details: WebExtension.CreateTabDetails, + ): GeckoResult<GeckoSession> { + assertThat(details.url, endsWith("options.html")) + assertEquals(details.active, true) + assertEquals(optionsExtension!!.id, source.id) + tabsCreateResult.complete(null) + return GeckoResult.fromValue(null) + } + } + + optionsExtension = sessionRule.waitForResult( + controller.installBuiltIn(OPENOPTIONSPAGE_1_BACKGROUND), + ) + optionsExtension.setTabDelegate(tabDelegate) + sessionRule.waitForResult(tabsCreateResult) + + sessionRule.waitForResult(controller.uninstall(optionsExtension)) + } + + // This test + // - Listen for an openOptionsPage request from a web extension + // - Registers a web extension + // - Waits for onOpenOptionsPage request + // - Verify that request came from right extension + @Test + fun testBrowserRuntimeOpenOptionsPageDelegate() { + val openOptionsPageResult = GeckoResult<Void>() + var optionsExtension: WebExtension? = null + val tabDelegate = object : WebExtension.TabDelegate { + @AssertCalled(count = 1) + override fun onOpenOptionsPage(source: WebExtension) { + assertThat( + source.metaData.optionsPageUrl, + endsWith("options.html"), + ) + assertEquals(optionsExtension!!.id, source.id) + openOptionsPageResult.complete(null) + } + } + + optionsExtension = sessionRule.waitForResult( + controller.installBuiltIn(OPENOPTIONSPAGE_2_BACKGROUND), + ) + optionsExtension.setTabDelegate(tabDelegate) + sessionRule.waitForResult(openOptionsPageResult) + + sessionRule.waitForResult(controller.uninstall(optionsExtension)) + } + + // This test checks if the request from Web Extension is processed correctly in Java + // the Boolean flags are true, other options have non-default values + @Test + fun testDownloadsFlagsTrue() { + val uri = createTestUrl("/assets/www/images/test.gif") + + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + return GeckoResult.allow() + } + }) + + val webExtension = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/download-flags-true.xpi"), + ) + + val assertOnDownloadCalled = GeckoResult<WebExtension.Download>() + val downloadDelegate = object : DownloadDelegate { + override fun onDownload(source: WebExtension, request: DownloadRequest): GeckoResult<DownloadInitData>? { + assertEquals(webExtension!!.id, source.id) + assertEquals(uri, request.request.uri) + assertEquals("POST", request.request.method) + + request.request.body?.rewind() + val result = Charset.forName("UTF-8").decode(request.request.body!!).toString() + assertEquals("postbody", result) + + assertEquals("Mozilla Firefox", request.request.headers.get("User-Agent")) + assertEquals("banana.gif", request.filename) + assertTrue(request.allowHttpErrors) + assertTrue(request.saveAs) + assertEquals(GeckoWebExecutor.FETCH_FLAGS_PRIVATE, request.downloadFlags) + assertEquals(DownloadRequest.CONFLICT_ACTION_OVERWRITE, request.conflictActionFlag) + + val download = controller.createDownload(1) + assertOnDownloadCalled.complete(download) + + val downloadInfo = object : Download.Info {} + + val initialData = DownloadInitData(download, downloadInfo) + return GeckoResult.fromValue(initialData) + } + } + + webExtension.setDownloadDelegate(downloadDelegate) + + mainSession.reload() + sessionRule.waitForPageStop() + + try { + sessionRule.waitForResult(assertOnDownloadCalled) + } catch (exception: UiThreadUtils.TimeoutException) { + controller.setAllowedInPrivateBrowsing(webExtension, true) + val downloadCreated = sessionRule.waitForResult(assertOnDownloadCalled) + assertNotNull(downloadCreated.id) + + sessionRule.waitForResult(controller.uninstall(webExtension)) + } + } + + // This test checks if the request from Web Extension is processed correctly in Java + // the Boolean flags are absent/false, other options have default values + @Test + fun testDownloadsFlagsFalse() { + val uri = createTestUrl("/assets/www/images/test.gif") + + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + return GeckoResult.allow() + } + }) + + val webExtension = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/download-flags-false.xpi"), + ) + + val assertOnDownloadCalled = GeckoResult<WebExtension.Download>() + val downloadDelegate = object : DownloadDelegate { + override fun onDownload(source: WebExtension, request: DownloadRequest): GeckoResult<DownloadInitData>? { + assertEquals(webExtension!!.id, source.id) + assertEquals(uri, request.request.uri) + assertEquals("GET", request.request.method) + assertNull(request.request.body) + assertEquals(0, request.request.headers.size) + assertNull(request.filename) + assertFalse(request.allowHttpErrors) + assertFalse(request.saveAs) + assertEquals(GeckoWebExecutor.FETCH_FLAGS_NONE, request.downloadFlags) + assertEquals(DownloadRequest.CONFLICT_ACTION_UNIQUIFY, request.conflictActionFlag) + + val download = controller.createDownload(2) + assertOnDownloadCalled.complete(download) + + val downloadInfo = object : Download.Info {} + + val initialData = DownloadInitData(download, downloadInfo) + return GeckoResult.fromValue(initialData) + } + } + + webExtension.setDownloadDelegate(downloadDelegate) + + mainSession.reload() + sessionRule.waitForPageStop() + + val downloadCreated = sessionRule.waitForResult(assertOnDownloadCalled) + assertNotNull(downloadCreated.id) + sessionRule.waitForResult(controller.uninstall(webExtension)) + } + + @Test + fun testOnChanged() { + val uri = createTestUrl("/assets/www/images/test.gif") + val downloadId = 4 + val unfinishedDownloadSize = 5L + val finishedDownloadSize = 25L + val expectedFilename = "test.gif" + val expectedMime = "image/gif" + val expectedEndTime = Date().time + val expectedFilesize = 48L + + // first and second update + val downloadData = object : Download.Info { + var endTime: Long? = null + val startTime = Date().time - 50000 + var fileExists = false + var totalBytes: Long = -1 + var mime = "" + var fileSize: Long = -1 + var filename = "" + var state = Download.STATE_IN_PROGRESS + + override fun state(): Int { + return state + } + + override fun endTime(): Long? { + return endTime + } + + override fun startTime(): Long { + return startTime + } + + override fun fileExists(): Boolean { + return fileExists + } + + override fun totalBytes(): Long { + return totalBytes + } + + override fun mime(): String { + return mime + } + + override fun fileSize(): Long { + return fileSize + } + + override fun filename(): String { + return filename + } + } + + val webExtension = sessionRule.waitForResult( + controller.installBuiltIn("resource://android/assets/web_extensions/download-onChanged/"), + ) + + val assertOnDownloadCalled = GeckoResult<Download>() + val downloadDelegate = object : DownloadDelegate { + override fun onDownload(source: WebExtension, request: DownloadRequest): GeckoResult<WebExtension.DownloadInitData>? { + assertEquals(webExtension!!.id, source.id) + assertEquals(uri, request.request.uri) + + val download = controller.createDownload(downloadId) + assertOnDownloadCalled.complete(download) + return GeckoResult.fromValue(DownloadInitData(download, downloadData)) + } + } + + val updates = mutableListOf<JSONObject>() + + val thirdUpdateReceived = GeckoResult<JSONObject>() + val messageDelegate = object : MessageDelegate { + override fun onMessage(nativeApp: String, message: Any, sender: MessageSender): GeckoResult<Any>? { + val current = (message as JSONObject).getJSONObject("current") + + updates.add(message) + + // Once we get the size finished download, that means we got the last update + if (current.getLong("totalBytes") == finishedDownloadSize) { + thirdUpdateReceived.complete(message) + } + + return GeckoResult.fromValue(message) + } + } + + webExtension.setDownloadDelegate(downloadDelegate) + webExtension.setMessageDelegate(messageDelegate, "browser") + + mainSession.reload() + sessionRule.waitForPageStop() + + val downloadCreated = sessionRule.waitForResult(assertOnDownloadCalled) + assertEquals(downloadId, downloadCreated.id) + + // first and second update (they are identical) + downloadData.filename = expectedFilename + downloadData.mime = expectedMime + downloadData.totalBytes = unfinishedDownloadSize + + downloadCreated.update(downloadData) + downloadCreated.update(downloadData) + + downloadData.fileSize = expectedFilesize + downloadData.endTime = expectedEndTime + downloadData.totalBytes = finishedDownloadSize + downloadData.state = Download.STATE_COMPLETE + downloadCreated.update(downloadData) + + sessionRule.waitForResult(thirdUpdateReceived) + + // The second update should not be there because the data was identical + assertEquals(2, updates.size) + + val firstUpdateCurrent = updates[0].getJSONObject("current") + val firstUpdatePrevious = updates[0].getJSONObject("previous") + assertEquals(3, firstUpdateCurrent.length()) + assertEquals(3, firstUpdatePrevious.length()) + assertEquals(expectedMime, firstUpdateCurrent.getString("mime")) + assertEquals("", firstUpdatePrevious.getString("mime")) + assertEquals(expectedFilename, firstUpdateCurrent.getString("filename")) + assertEquals("", firstUpdatePrevious.getString("filename")) + assertEquals(unfinishedDownloadSize, firstUpdateCurrent.getLong("totalBytes")) + assertEquals(-1, firstUpdatePrevious.getLong("totalBytes")) + + val secondUpdateCurrent = updates[1].getJSONObject("current") + val secondUpdatePrevious = updates[1].getJSONObject("previous") + assertEquals(4, secondUpdateCurrent.length()) + assertEquals(4, secondUpdatePrevious.length()) + assertEquals(finishedDownloadSize, secondUpdateCurrent.getLong("totalBytes")) + assertEquals(firstUpdateCurrent.getLong("totalBytes"), secondUpdatePrevious.getLong("totalBytes")) + assertEquals("complete", secondUpdateCurrent.get("state").toString()) + assertEquals("in_progress", secondUpdatePrevious.get("state").toString()) + assertEquals(expectedEndTime.toString(), secondUpdateCurrent.getString("endTime")) + assertEquals("null", secondUpdatePrevious.getString("endTime")) + assertEquals(expectedFilesize, secondUpdateCurrent.getLong("fileSize")) + assertEquals(-1, secondUpdatePrevious.getLong("fileSize")) + + sessionRule.waitForResult(controller.uninstall(webExtension)) + } + + @Test + fun testOnChangedWrongId() { + val uri = createTestUrl("/assets/www/images/test.gif") + val downloadId = 5 + + val webExtension = sessionRule.waitForResult( + controller.installBuiltIn("resource://android/assets/web_extensions/download-onChanged/"), + ) + + val assertOnDownloadCalled = GeckoResult<WebExtension.Download>() + val downloadDelegate = object : DownloadDelegate { + override fun onDownload(source: WebExtension, request: DownloadRequest): GeckoResult<WebExtension.DownloadInitData>? { + assertEquals(webExtension!!.id, source.id) + assertEquals(uri, request.request.uri) + + val download = controller.createDownload(downloadId) + assertOnDownloadCalled.complete(download) + return GeckoResult.fromValue(DownloadInitData(download, object : Download.Info {})) + } + } + + val onMessageCalled = GeckoResult<String>() + val messageDelegate = object : MessageDelegate { + override fun onMessage(nativeApp: String, message: Any, sender: MessageSender): GeckoResult<Any>? { + onMessageCalled.complete(message as String) + return GeckoResult.fromValue(message) + } + } + + webExtension.setDownloadDelegate(downloadDelegate) + webExtension.setMessageDelegate(messageDelegate, "browser") + + mainSession.reload() + sessionRule.waitForPageStop() + + val updateData = object : WebExtension.Download.Info { + override fun state(): Int { + return WebExtension.Download.STATE_COMPLETE + } + } + + val randomDownload = controller.createDownload(25) + + val r = randomDownload!!.update(updateData) + + try { + sessionRule.waitForResult(r!!) + } catch (ex: Exception) { + val a = ex.message!! + assertEquals("Error: Trying to update unknown download", a) + sessionRule.waitForResult(controller.uninstall(webExtension)) + return + } + } +} |