chromium/android_webview/javatests/src/org/chromium/android_webview/test/AwPermissionQueryApiTest.java

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.android_webview.test;

import androidx.test.filters.SmallTest;

import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.UseParametersRunnerFactory;

import org.chromium.android_webview.AwContents;
import org.chromium.android_webview.AwGeolocationPermissions;
import org.chromium.android_webview.test.TestWebMessageListener.Data;
import org.chromium.android_webview.test.util.CommonResources;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.ThreadUtils;
import org.chromium.base.test.util.Batch;
import org.chromium.base.test.util.CommandLineFlags;
import org.chromium.base.test.util.Feature;
import org.chromium.content_public.common.ContentFeatures;
import org.chromium.device.geolocation.LocationProviderOverrider;
import org.chromium.device.geolocation.MockLocationProvider;
import org.chromium.net.test.util.TestWebServer;

import java.util.concurrent.atomic.AtomicInteger;

/**
 * Test the navigator.permissions.query web API in WebView.
 *
 * <p>Canonical list of permission enum constants is located at
 * https://crsrc.org/c/third_party/blink/renderer/modules/permissions/permission_descriptor.idl.
 */
@RunWith(Parameterized.class)
@UseParametersRunnerFactory(AwJUnit4ClassRunnerWithParameters.Factory.class)
@CommandLineFlags.Add({"enable-features=" + ContentFeatures.WEB_PERMISSIONS_API})
@Batch(Batch.PER_CLASS)
public class AwPermissionQueryApiTest extends AwParameterizedTest {

    // Script template to query a permission and report the result back using the injected listener.
    private static final String QUERY_API_PERMISSION =
            """
          <html>
          <script>
          navigator.permissions.query(%s).then((result) => {
            resultListener.postMessage(result.state);
          }).catch(e => {
            if (e instanceof TypeError && e.message.includes("is not enabled")) {
                resultListener.postMessage("not_enabled");
            } else {
                resultListener.postMessage("" + e);
            }
          });
          </script>
      """;

    /**
     * A page that queries geolocation, asks for a position, and then queries the permission state
     * again.
     */
    private static final String GEOLOCATION_PAGE_HTML =
            """
        <!DOCTYPE html>
        <html>
          <head>
            <title>Geolocation</title>
            <script>
              function gotPos(position) {
                resultListener.postMessage("position");
                navigator.permissions.query({"name": "geolocation"}).then((result) => {
                    resultListener.postMessage(result.state);
                }).catch(e => resultListener.postMessage("" + e));
              }
              function errorCallback(error){
                resultListener.postMessage("" + error);
              }
              function initiate_getCurrentPosition() {
                navigator.geolocation.getCurrentPosition(gotPos, errorCallback, { });
              }
              navigator.permissions.query({"name": "geolocation"}).then((result) => {
                resultListener.postMessage(result.state);
                initiate_getCurrentPosition();
              }).catch(e => resultListener.postMessage("" + e));
            </script>
          </head>
          <body>
          </body>
        </html>""";

    @Rule public AwActivityTestRule mActivityTestRule;

    private AwContents mAwContents;
    private TestWebMessageListener mWebMessageListener;
    private TestWebServer mServer;
    private GeolocationAwContentsClient mContentsClient;

    public AwPermissionQueryApiTest(AwSettingsMutation params) {
        this.mActivityTestRule = new AwActivityTestRule(params.getMutation());
    }

    private interface OnGeolocationCallback {

        void onGeolocationPermissionsShowPrompt(
                String origin, AwGeolocationPermissions.Callback callback);
    }

    private static class GeolocationAwContentsClient extends TestAwContentsClient {

        private OnGeolocationCallback mGeolocationCallback;

        @Override
        public void onGeolocationPermissionsShowPrompt(
                String origin, AwGeolocationPermissions.Callback callback) {
            if (mGeolocationCallback != null) {
                mGeolocationCallback.onGeolocationPermissionsShowPrompt(origin, callback);
            } else {
                super.onGeolocationPermissionsShowPrompt(origin, callback);
            }
        }

        public void setGeolocationCallback(OnGeolocationCallback geolocationCallback) {
            this.mGeolocationCallback = geolocationCallback;
        }
    }

    @Before
    public void setUp() throws Exception {

        mContentsClient = new GeolocationAwContentsClient();
        final AwTestContainerView testContainerView =
                mActivityTestRule.createAwTestContainerViewOnMainSync(mContentsClient);
        mAwContents = testContainerView.getAwContents();
        AwActivityTestRule.enableJavaScriptOnUiThread(mAwContents);

        // The test mutations disable geolocation, but the tests in this class
        // all assume that geolocation is enabled, and there is an explicit test to
        // assert behavior when geolocation is disabled. Since the mutations
        // framework injects a mutation for each class, this change should not leak
        // beyond this test class.
        setGeolocationEnabledOnUiThread(true);

        mWebMessageListener = new TestWebMessageListener();
        TestWebMessageListener.addWebMessageListenerOnUiThread(
                mAwContents, "resultListener", new String[] {"*"}, mWebMessageListener);

        mServer = TestWebServer.start();
    }

    private void setGeolocationEnabledOnUiThread(boolean enabled) {
        ThreadUtils.runOnUiThreadBlocking(
                () -> mAwContents.getSettings().setGeolocationEnabled(enabled));
    }

    @After
    public void tearDown() throws Exception {
        mServer.close();
        // Clear all stored geolocation permissions after each test.
        ThreadUtils.runOnUiThreadBlocking(() -> mAwContents.getGeolocationPermissions().clearAll());
    }

    @Test
    @Feature({"AndroidWebView"})
    @SmallTest
    public void testPermissionsAllowed() throws Exception {
        // These permissions are auto-granted by WebView.
        runTestCase("accelerometer", "granted");
        runTestCase("gyroscope", "granted");
        runTestCase("midi", "granted");
        runTestCase("magnetometer", "granted");
    }

    @Test
    @Feature({"AndroidWebView"})
    @SmallTest
    public void testPermissionsPrompt() throws Exception {
        // These permissions require a user prompt.
        runTestCase("camera", "prompt");
        runTestCase("clipboard-write", "prompt");
        runTestCase("geolocation", "prompt");
        runTestCase("microphone", "prompt");
        runTestCase("midi-sysex", "prompt", "{\"name\": \"midi\", \"sysex\": true}");
    }

    @Test
    @Feature({"AndroidWebView"})
    @SmallTest
    public void testPermissionsDenied() throws Exception {
        // These permissions are not supported by WebView and auto-denied.
        runTestCase("background-sync", "denied");
        runTestCase("clipboard-read", "denied");
        runTestCase("notifications", "denied");
        runTestCase("payment-handler", "denied");
        runTestCase("persistent-storage", "denied");
        runTestCase("screen-wake-lock", "denied");
        runTestCase("storage-access", "denied");
        runTestCase("window-management", "denied");
        runTestCase("background-fetch", "denied");
        runTestCase("screen-wake-lock", "denied");
        runTestCase("nfc", "denied");
        runTestCase("display-capture", "denied");
        runTestCase("idle-detection", "denied");
        runTestCase("periodic-background-sync", "denied");
        runTestCase("keyboard-lock", "denied");
        runTestCase("push", "denied", "{\"name\": \"push\", \"userVisibleOnly\": true}");
        runTestCase(
                "top-level-storage-access",
                "denied",
                "{\"name\": \"top-level-storage-access\", \"requestedOrigin\":"
                        + " \"https://example.com\"}");
        runTestCase("pointer-lock", "denied");
        runTestCase(
                "fullscreen",
                "denied",
                "{\"name\": \"fullscreen\", \"allowWithoutGesture\": true}");
    }

    @Test
    @Feature({"AndroidWebView"})
    @SmallTest
    public void testPermissionsNotEnabled() throws Exception {
        // These permissions are blocked behind feature flags that are not enabled
        // in WebView.
        runTestCase("ambient-light-sensor", "not_enabled");
        runTestCase("accessibility-events", "not_enabled");
        runTestCase("system-wake-lock", "not_enabled");
        runTestCase("local-fonts", "not_enabled");
        runTestCase("captured-surface-control", "not_enabled");
        runTestCase("speaker-selection", "not_enabled");
    }

    @Test
    @Feature({"AndroidWebView"})
    @SmallTest
    public void testGeolocationDisabled() throws Exception {
        setGeolocationEnabledOnUiThread(false);

        runTestCase("geolocation", "denied");
    }

    @Test
    @Feature({"AndroidWebView"})
    @SmallTest
    public void testGeolocationGranted() throws Exception {
        final String serverOrigin = mServer.getBaseUrl();
        ThreadUtils.runOnUiThreadBlocking(
                () -> mAwContents.getGeolocationPermissions().allow(serverOrigin));

        runTestCase("geolocation", "granted");
    }

    @Test
    @Feature({"AndroidWebView"})
    @SmallTest
    public void testGeolocationChangeToGrantedIfRetained() throws Exception {
        MockLocationProvider provider = new MockLocationProvider();
        LocationProviderOverrider.setLocationProviderImpl(provider);
        ResettersForTesting.register(provider::stopUpdates);

        AtomicInteger callbackCounter = new AtomicInteger();
        // Set up an embedding app callback that allows the origin and retains the setting.
        final String serverOrigin = mServer.getResponseUrl("");
        mContentsClient.setGeolocationCallback(
                (origin, callback) -> {
                    callbackCounter.incrementAndGet();
                    boolean allowed = origin.startsWith(serverOrigin);
                    callback.invoke(origin, allowed, /* retain= */ true);
                });

        String pageUrl =
                mServer.setResponse(
                        "/geolocation",
                        GEOLOCATION_PAGE_HTML,
                        CommonResources.getTextHtmlHeaders(true));
        mActivityTestRule.loadUrlAsync(mAwContents, pageUrl);

        Assert.assertEquals(
                "Permission should not be granted initially",
                "prompt",
                mWebMessageListener.waitForOnPostMessage().getAsString());
        Assert.assertEquals(
                "The web page did not get the current position",
                "position",
                mWebMessageListener.waitForOnPostMessage().getAsString());
        Assert.assertEquals(
                "Permission should be granted once the app has persisted the result",
                "granted",
                mWebMessageListener.waitForOnPostMessage().getAsString());
        Assert.assertEquals(
                "The app callback should only be invoked once", 1, callbackCounter.get());

        // Try to load a new page on the same origin and check that the permission is still granted.
        runTestCase("geolocation", "granted");
        Assert.assertEquals(
                "The app callback should only be invoked once", 1, callbackCounter.get());
    }

    @Test
    @Feature({"AndroidWebView"})
    @SmallTest
    public void testGeolocationStaysOnPromptIfNotRetained() throws Exception {
        MockLocationProvider provider = new MockLocationProvider();
        LocationProviderOverrider.setLocationProviderImpl(provider);
        ResettersForTesting.register(provider::stopUpdates);

        AtomicInteger callbackCounter = new AtomicInteger();
        // Set up an embedding app callback that allows the origin and retains the setting.
        final String serverOrigin = mServer.getResponseUrl("");
        mContentsClient.setGeolocationCallback(
                (origin, callback) -> {
                    callbackCounter.incrementAndGet();
                    boolean allowed = origin.startsWith(serverOrigin);
                    callback.invoke(origin, allowed, /* retain= */ false);
                });

        String pageUrl =
                mServer.setResponse(
                        "/geolocation",
                        GEOLOCATION_PAGE_HTML,
                        CommonResources.getTextHtmlHeaders(true));
        mActivityTestRule.loadUrlAsync(mAwContents, pageUrl);

        Assert.assertEquals(
                "Permission should not be granted initially",
                "prompt",
                mWebMessageListener.waitForOnPostMessage().getAsString());
        Assert.assertEquals(
                "The web page did not get the current position",
                "position",
                mWebMessageListener.waitForOnPostMessage().getAsString());
        Assert.assertEquals(
                "Permission should not be granted when page didn't request retain",
                "prompt",
                mWebMessageListener.waitForOnPostMessage().getAsString());
        Assert.assertEquals(
                "The app callback should only be invoked once", 1, callbackCounter.get());
    }

    private void runTestCase(String permission, String expected) throws Exception {
        runTestCase(permission, expected, String.format("{\"name\": \"%s\"}", permission));
    }

    private void runTestCase(String permission, String expected, String queryObjectString)
            throws Exception {
        String html = String.format(QUERY_API_PERMISSION, queryObjectString);
        String pageUrl =
                mServer.setResponse("/permissions", html, CommonResources.getTextHtmlHeaders(true));
        mActivityTestRule.loadUrlAsync(mAwContents, pageUrl);
        Data data = mWebMessageListener.waitForOnPostMessage();
        Assert.assertEquals(
                "Got unexpected result for " + permission, expected, data.getAsString());
    }
}