// Copyright 2015 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 android.os.Handler;
import android.os.Looper;
import androidx.annotation.Nullable;
import androidx.test.filters.SmallTest;
import org.json.JSONArray;
import org.json.JSONException;
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.common.AwFeatures;
import org.chromium.android_webview.permission.AwPermissionRequest;
import org.chromium.android_webview.test.util.CommonResources;
import org.chromium.base.BuildInfo;
import org.chromium.base.test.util.CommandLineFlags;
import org.chromium.base.test.util.Feature;
import org.chromium.base.test.util.Features;
import org.chromium.base.test.util.HistogramWatcher;
import org.chromium.content_public.browser.test.util.DomAutomationController;
import org.chromium.content_public.browser.test.util.JavaScriptUtils;
import org.chromium.content_public.common.ContentSwitches;
import org.chromium.net.test.util.TestWebServer;
/** Test AwPermissionManager. */
@RunWith(Parameterized.class)
@UseParametersRunnerFactory(AwJUnit4ClassRunnerWithParameters.Factory.class)
public class AwPermissionManagerTest extends AwParameterizedTest {
@Rule public AwActivityTestRule mActivityTestRule;
private static final String REQUEST_DUPLICATE =
"<html> <script>"
+ "navigator.requestMIDIAccess({sysex: true}).then(function() {"
+ "});"
+ "navigator.requestMIDIAccess({sysex: true}).then(function() {"
+ " window.document.title = 'second-granted';"
+ "});"
+ "</script><body>"
+ "</body></html>";
private static final String EMPTY_PAGE =
"<html><script>" + "</script><body>" + "</body></html>";
private static final String IFRAME_PARENT_PAGE = "<html><iframe></iframe><body></body></html>";
private static final String REQUEST_STORAGE_ACCESS_PAGE =
"""
<html>
<body>
<script>
document.requestStorageAccess()
.then(() => window.parent.postMessage('granted', '*'))
.catch((e) => window.parent.postMessage('not granted', '*'));
</script>
</body>
</html>""";
private static final String GUM_JS =
"navigator.mediaDevices.getUserMedia({video: true, audio: true})"
+ ".then((_) => domAutomationController.send('success'))"
+ ".catch((error) => domAutomationController.send('failure'));";
private static final String ENUMERATE_DEVICES_JS =
"navigator.mediaDevices.enumerateDevices().then("
+ "(devices) => domAutomationController.send(devices.map("
+ " (d) => `${d['label']}`)));";
private static final String ASSET_STATEMENT_TEMPLATE =
"""
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "%s",
"sha256_cert_fingerprints": ["%s"]
}
}]
""";
private static final String ASSET_STATEMENT_PATH = "/.well-known/assetlinks.json";
private static final String SAA_GRANT_TIME_HISTOGRAM =
"Android.WebView.StorageAccessAutoGrantTime";
private final DomAutomationController mDomAutomationController = new DomAutomationController();
private TestWebServer mTestWebServer;
private String mPage;
private TestAwContentsClient mContentsClient;
public AwPermissionManagerTest(AwSettingsMutation param) {
this.mActivityTestRule = new AwActivityTestRule(param.getMutation());
}
@Before
public void setUp() throws Exception {
mTestWebServer = TestWebServer.start();
}
@After
public void tearDown() {
mTestWebServer.shutdown();
mTestWebServer = null;
}
@Test
@Feature({"AndroidWebView"})
@SmallTest
public void testRequestMultiple() {
mPage =
mTestWebServer.setResponse(
"/permissions",
REQUEST_DUPLICATE,
CommonResources.getTextHtmlHeaders(true));
mContentsClient =
new TestAwContentsClient() {
private boolean mCalled;
@Override
public void onPermissionRequest(final AwPermissionRequest awPermissionRequest) {
if (mCalled) {
Assert.fail("Only one request was expected");
return;
}
mCalled = true;
// Emulate a delayed response to the request by running four seconds in the
// future.
Handler handler = new Handler(Looper.myLooper());
handler.postDelayed(awPermissionRequest::grant, 4000);
}
};
final AwTestContainerView testContainerView =
mActivityTestRule.createAwTestContainerViewOnMainSync(mContentsClient);
final AwContents awContents = testContainerView.getAwContents();
AwActivityTestRule.enableJavaScriptOnUiThread(awContents);
mActivityTestRule.loadUrlAsync(awContents, mPage, null);
pollTitleAs("second-granted", awContents);
}
@Test
@Feature({"AndroidWebView"})
@SmallTest
@CommandLineFlags.Add({ContentSwitches.USE_FAKE_DEVICE_FOR_MEDIA_STREAM})
public void testRequestMediaPermissions() throws Exception {
AwContents awContents = setUpEnumerateDevicesTest(null);
mActivityTestRule.loadUrlSync(awContents, mContentsClient.getOnPageFinishedHelper(), mPage);
JavaScriptUtils.runJavascriptWithUserGestureAndAsyncResult(
awContents.getWebContents(), GUM_JS);
String devices =
JavaScriptUtils.runJavascriptWithUserGestureAndAsyncResult(
awContents.getWebContents(), ENUMERATE_DEVICES_JS);
assertDeviceLabels(devices, false);
}
@Test
@Feature({"AndroidWebView"})
@SmallTest
@CommandLineFlags.Add({ContentSwitches.USE_FAKE_DEVICE_FOR_MEDIA_STREAM})
public void testNavigationRevokesEnumerateDevicesLabelsPermissions() throws Exception {
AwContents awContents = setUpEnumerateDevicesTest(null);
TestWebServer secondServer = TestWebServer.startAdditional();
String secondPage =
secondServer.setResponse(
"/new-page", EMPTY_PAGE, CommonResources.getTextHtmlHeaders(true));
mActivityTestRule.loadUrlSync(awContents, mContentsClient.getOnPageFinishedHelper(), mPage);
JavaScriptUtils.runJavascriptWithUserGestureAndAsyncResult(
awContents.getWebContents(), GUM_JS);
JavaScriptUtils.runJavascriptWithUserGestureAndAsyncResult(
awContents.getWebContents(), ENUMERATE_DEVICES_JS);
// Navigate to a page with a different origin.
mActivityTestRule.loadUrlSync(
awContents, mContentsClient.getOnPageFinishedHelper(), secondPage);
String devices =
JavaScriptUtils.runJavascriptWithUserGestureAndAsyncResult(
awContents.getWebContents(), ENUMERATE_DEVICES_JS);
assertDeviceLabels(devices, true);
}
@Test
@Feature({"AndroidWebView"})
@SmallTest
@CommandLineFlags.Add({ContentSwitches.USE_FAKE_DEVICE_FOR_MEDIA_STREAM})
public void testEnumerateDevicesWithAllowFileAccessFromFileURLsFalse() throws Throwable {
AwContents awContents = setUpEnumerateDevicesTest(null);
awContents.getSettings().setAllowFileAccessFromFileURLs(false);
mActivityTestRule.loadDataWithBaseUrlSync(
awContents,
mContentsClient.getOnPageFinishedHelper(),
EMPTY_PAGE,
"text/html",
true,
"file:///foo.html",
"");
JavaScriptUtils.runJavascriptWithUserGestureAndAsyncResult(
awContents.getWebContents(), GUM_JS);
String devices =
JavaScriptUtils.runJavascriptWithUserGestureAndAsyncResult(
awContents.getWebContents(), ENUMERATE_DEVICES_JS);
assertDeviceLabels(devices, true);
}
@Test
@Feature({"AndroidWebView"})
@SmallTest
@CommandLineFlags.Add({ContentSwitches.USE_FAKE_DEVICE_FOR_MEDIA_STREAM})
public void testEnumerateDevicesWithAllowFileAccessFromFileURLsTrue() throws Throwable {
AwContents awContents = setUpEnumerateDevicesTest(null);
awContents.getSettings().setAllowFileAccessFromFileURLs(true);
mActivityTestRule.loadDataWithBaseUrlSync(
awContents,
mContentsClient.getOnPageFinishedHelper(),
EMPTY_PAGE,
"text/html",
true,
"file:///foo.html",
"");
JavaScriptUtils.runJavascriptWithUserGestureAndAsyncResult(
awContents.getWebContents(), GUM_JS);
String devices =
JavaScriptUtils.runJavascriptWithUserGestureAndAsyncResult(
awContents.getWebContents(), ENUMERATE_DEVICES_JS);
assertDeviceLabels(devices, false);
}
// Test that a successful getUserMedia grants enumerateDevices permission for
// all file:/// URLs when AllowFileAccessFromFileURLs is enabled.
@Test
@Feature({"AndroidWebView"})
@SmallTest
@CommandLineFlags.Add({ContentSwitches.USE_FAKE_DEVICE_FOR_MEDIA_STREAM})
public void testPermissionIsCachedAfterFileNavigation() throws Throwable {
AwContents awContents = setUpEnumerateDevicesTest(null);
awContents.getSettings().setAllowFileAccessFromFileURLs(true);
mActivityTestRule.loadDataWithBaseUrlSync(
awContents,
mContentsClient.getOnPageFinishedHelper(),
EMPTY_PAGE,
"text/html",
true,
"file:///foo.html",
"");
JavaScriptUtils.runJavascriptWithUserGestureAndAsyncResult(
awContents.getWebContents(), GUM_JS);
// Navigate to a different file URL.
mActivityTestRule.loadDataWithBaseUrlSync(
awContents,
mContentsClient.getOnPageFinishedHelper(),
EMPTY_PAGE,
"text/html",
true,
"file:///bar.html",
"");
String devices =
JavaScriptUtils.runJavascriptWithUserGestureAndAsyncResult(
awContents.getWebContents(), ENUMERATE_DEVICES_JS);
assertDeviceLabels(devices, false);
}
@Test
@Feature({"AndroidWebView"})
@SmallTest
@CommandLineFlags.Add({ContentSwitches.USE_FAKE_DEVICE_FOR_MEDIA_STREAM})
public void testEnumerateDevicesDoesNotShowLabelsBeforeGetUserMedia() throws Exception {
AwContents awContents = setUpEnumerateDevicesTest(null);
mActivityTestRule.loadUrlSync(awContents, mContentsClient.getOnPageFinishedHelper(), mPage);
String devices =
JavaScriptUtils.runJavascriptWithUserGestureAndAsyncResult(
awContents.getWebContents(), ENUMERATE_DEVICES_JS);
assertDeviceLabels(devices, true);
}
@Test
@Feature({"AndroidWebView"})
@SmallTest
@CommandLineFlags.Add({ContentSwitches.USE_FAKE_DEVICE_FOR_MEDIA_STREAM})
public void testRevokeEnumerateDevicesPermission() throws Exception {
AwContents awContents =
setUpEnumerateDevicesTest(
new TestAwContentsClient() {
private boolean mHasBeenGranted;
@Override
public void onPermissionRequest(
AwPermissionRequest awPermissionRequest) {
if (mHasBeenGranted) {
awPermissionRequest.deny();
return;
}
mHasBeenGranted = true;
awPermissionRequest.grant();
}
});
mActivityTestRule.loadUrlSync(awContents, mContentsClient.getOnPageFinishedHelper(), mPage);
JavaScriptUtils.runJavascriptWithUserGestureAndAsyncResult(
awContents.getWebContents(), GUM_JS);
JavaScriptUtils.runJavascriptWithUserGestureAndAsyncResult(
awContents.getWebContents(), ENUMERATE_DEVICES_JS);
JavaScriptUtils.runJavascriptWithUserGestureAndAsyncResult(
awContents.getWebContents(), GUM_JS);
String devices =
JavaScriptUtils.runJavascriptWithUserGestureAndAsyncResult(
awContents.getWebContents(), ENUMERATE_DEVICES_JS);
assertDeviceLabels(devices, true);
}
@Test
@Feature({"AndroidWebView"})
@SmallTest
public void testStorageAccessMetricLogged() throws Exception {
var histogramWatcher =
HistogramWatcher.newBuilder()
.expectAnyRecord("Android.WebView.StorageAccessRelation2")
.build();
String result = requestEmbeddedStorageAccess(/* isInAppStatement= */ true);
// The storage access API doesn't work by default on WebView.
Assert.assertEquals("\"not granted\"", result);
histogramWatcher.pollInstrumentationThreadUntilSatisfied();
}
@Test
@Feature({"AndroidWebView"})
@SmallTest
@Features.EnableFeatures({AwFeatures.WEBVIEW_AUTO_SAA})
public void testAutoGrantSAA_trusted() throws Exception {
var histogramWatcher =
HistogramWatcher.newBuilder().expectAnyRecord(SAA_GRANT_TIME_HISTOGRAM).build();
var buildInfo = BuildInfo.getInstance();
// We add an asset statement to always trust the test app for auto granting.
mTestWebServer.setResponse(
ASSET_STATEMENT_PATH,
String.format(
ASSET_STATEMENT_TEMPLATE,
buildInfo.hostPackageName,
buildInfo.getHostSigningCertSha256()),
null);
String result = requestEmbeddedStorageAccess(/* isInAppStatement= */ true);
Assert.assertEquals("\"granted\"", result);
histogramWatcher.pollInstrumentationThreadUntilSatisfied();
// Confirm this is resolved against the test server the first time
Assert.assertEquals(1, mTestWebServer.getRequestCount(ASSET_STATEMENT_PATH));
result = requestEmbeddedStorageAccess(/* isInAppStatement= */ true);
Assert.assertEquals("\"granted\"", result);
// Confirm that subsequent calls are from cached results
Assert.assertEquals(1, mTestWebServer.getRequestCount(ASSET_STATEMENT_PATH));
}
@Test
@Feature({"AndroidWebView"})
@SmallTest
@Features.EnableFeatures({AwFeatures.WEBVIEW_AUTO_SAA})
public void testAutoGrantSAA_untrustedDomain() throws Exception {
var histogramWatcher =
HistogramWatcher.newBuilder().expectNoRecords(SAA_GRANT_TIME_HISTOGRAM).build();
var buildInfo = BuildInfo.getInstance();
// We add an asset statement to always trust the test app for auto granting.
mTestWebServer.setResponse(
ASSET_STATEMENT_PATH,
String.format(
ASSET_STATEMENT_TEMPLATE,
buildInfo.hostPackageName,
buildInfo.getHostSigningCertSha256()),
null);
String result = requestEmbeddedStorageAccess(/* isInAppStatement= */ false);
Assert.assertEquals("\"not granted\"", result);
histogramWatcher.pollInstrumentationThreadUntilSatisfied();
}
@Test
@Feature({"AndroidWebView"})
@SmallTest
@Features.EnableFeatures({AwFeatures.WEBVIEW_AUTO_SAA})
public void testAutoGrantSAA_untrustedApp() throws Exception {
var histogramWatcher =
HistogramWatcher.newBuilder().expectAnyRecord(SAA_GRANT_TIME_HISTOGRAM).build();
// In this test's case, we make the site only trust an app we are not.
mTestWebServer.setResponse(
ASSET_STATEMENT_PATH,
String.format(ASSET_STATEMENT_TEMPLATE, "some other app", "some hash"),
null);
String result = requestEmbeddedStorageAccess(/* isInAppStatement= */ true);
Assert.assertEquals("\"not granted\"", result);
histogramWatcher.pollInstrumentationThreadUntilSatisfied();
}
private String requestEmbeddedStorageAccess(boolean isInAppStatement) throws Exception {
var contentsClient = new TestAwContentsClient();
final AwContents awContents =
mActivityTestRule
.createAwTestContainerViewOnMainSync(contentsClient)
.getAwContents();
AwActivityTestRule.enableJavaScriptOnUiThread(awContents);
// We need to request storage access from within an iframe, otherwise it will
// just auto resolve to granted.
// The iframe will load, request storage access, and post the result back.
var storagePage = mTestWebServer.setResponse("/storage", REQUEST_STORAGE_ACCESS_PAGE, null);
var parentPage = mTestWebServer.setResponse("/", IFRAME_PARENT_PAGE, null);
// The test app trusts localhost. To test a flow where we don't have
// the website in our apps asset statement, we can just use a IP address
// that the app hasn't declared but still resolves.
if (!isInAppStatement) {
storagePage = storagePage.replace("localhost", "127.0.0.1");
parentPage = parentPage.replace("localhost", "127.0.0.1");
}
mActivityTestRule.loadUrlSync(
awContents, contentsClient.getOnPageFinishedHelper(), parentPage);
// We add an event listener for the result from the iframe and then initiate the page
// load.
return JavaScriptUtils.runJavascriptWithUserGestureAndAsyncResult(
awContents.getWebContents(),
String.format(
"""
window.addEventListener('message', (e) => {
domAutomationController.send(e.data)
});
document.querySelector('iframe').src = "%s";""",
storagePage));
}
private void pollTitleAs(final String title, final AwContents awContents) {
AwActivityTestRule.pollInstrumentationThread(
() -> title.equals(mActivityTestRule.getTitleOnUiThread(awContents)));
}
private AwContents setUpEnumerateDevicesTest(@Nullable TestAwContentsClient contentsClient)
throws Exception {
mPage =
mTestWebServer.setResponse(
"/media", EMPTY_PAGE, CommonResources.getTextHtmlHeaders(true));
mContentsClient =
contentsClient != null
? contentsClient
: new TestAwContentsClient() {
@Override
public void onPermissionRequest(
final AwPermissionRequest awPermissionRequest) {
awPermissionRequest.grant();
}
};
final AwTestContainerView testContainerView =
mActivityTestRule.createAwTestContainerViewOnMainSync(mContentsClient);
AwContents awContents = testContainerView.getAwContents();
mDomAutomationController.inject(awContents.getWebContents());
AwActivityTestRule.enableJavaScriptOnUiThread(awContents);
return awContents;
}
private void assertDeviceLabels(String devices, boolean shouldBeEmpty) throws JSONException {
JSONArray devicesJson = new JSONArray(devices);
boolean isEmpty = true;
for (int i = 0; i < devicesJson.length(); i++) {
if (!devicesJson.getString(i).isEmpty()) {
isEmpty = false;
break;
}
}
if (shouldBeEmpty) {
Assert.assertTrue(isEmpty);
} else {
Assert.assertFalse(isEmpty);
}
}
}