chromium/chrome/test/data/webui/settings/unused_site_permissions_test.ts

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

// clang-format off
import {webUIListenerCallback} from 'chrome://resources/js/cr.js';
import {keyDownOn} from 'chrome://webui-test/keyboard_mock_interactions.js';
import {flush} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {flushTasks} from 'chrome://webui-test/polymer_test_util.js';
import {assertDeepEquals, assertEquals, assertFalse, assertTrue} from 'chrome://webui-test/chai_assert.js';
import type {SettingsUnusedSitePermissionsElement, UnusedSitePermissions} from 'chrome://settings/lazy_load.js';
import {ContentSettingsTypes, SafetyHubBrowserProxyImpl, SafetyHubEvent} from 'chrome://settings/lazy_load.js';
import {MetricsBrowserProxyImpl, resetRouterForTesting, Router, routes, SafetyCheckUnusedSitePermissionsModuleInteractions} from 'chrome://settings/settings.js';
import {isMac} from 'chrome://resources/js/platform.js';
import {PluralStringProxyImpl} from 'chrome://resources/js/plural_string_proxy.js';

import {TestMetricsBrowserProxy} from './test_metrics_browser_proxy.js';
import {TestSafetyHubBrowserProxy} from './test_safety_hub_browser_proxy.js';

// clang-format on

suite('CrSettingsUnusedSitePermissionsTest', function() {
  let browserProxy: TestSafetyHubBrowserProxy;
  let metricsBrowserProxy: TestMetricsBrowserProxy;

  let testElement: SettingsUnusedSitePermissionsElement;

  const permissions = [
    ContentSettingsTypes.GEOLOCATION,
    ContentSettingsTypes.MIC,
    ContentSettingsTypes.CAMERA,
    ContentSettingsTypes.NOTIFICATIONS,
  ];

  const mockData = [1, 2, 3, 4].map(
      i => ({
        origin: `https://www.example${i}.com:443`,
        permissions: permissions.slice(0, i),
        expiration: '13317004800000000',  // Represents 2023-01-01T00:00:00.
      }));

  /* Asserts for each row whether or not it is animating. */
  function assertAnimation(expectedAnimation: boolean[]) {
    const rows = getSiteList();

    assertEquals(
        rows.length, expectedAnimation.length,
        'Provided ' + expectedAnimation.length +
            ' expectations but there are ' + rows.length + ' rows');
    for (const [i, row] of rows.entries()) {
      assertEquals(
          expectedAnimation[i]!, row!.classList.contains('removed'),
          'Expectation not met for row #' + i);
    }
  }

  function assertEqualsMockData(siteList: UnusedSitePermissions[]) {
    // |siteList| coming from WebUI may have the additional property |visible|,
    // so assertDeepEquals doesn't work to compare it with |mockData|. We care
    // about origins and associated permissions being equal.
    assertEquals(siteList.length, mockData.length);
    for (const [i, site] of siteList.entries()) {
      assertEquals(site!.origin, mockData[i]!.origin);
      assertDeepEquals(site!.permissions, mockData[i]!.permissions);
    }
  }

  function assertInitialUi() {
    const expectedSiteCount = mockData.length;
    assertEquals(getSiteList().length, expectedSiteCount);
    assertAnimation(new Array(expectedSiteCount).fill(false));
    assertToast(false);
  }

  /** Assert visibility and content of the undo toast. */
  function assertToast(shouldBeOpen: boolean, expectedText?: string) {
    const undoToast = testElement.shadowRoot!.querySelector('cr-toast')!;
    if (!shouldBeOpen) {
      assertFalse(undoToast.open);
      return;
    }
    assertTrue(undoToast.open);
    const actualText = undoToast.querySelector('div')!.textContent!.trim();
    assertEquals(expectedText, actualText);
  }

  function clickGotIt() {
    const button = testElement.shadowRoot!.querySelector<HTMLElement>(
        '.bulk-action-button');
    assertTrue(!!button);
    button.click();
  }

  function clickUndo() {
    testElement.shadowRoot!.querySelector(
                               'cr-toast')!.querySelector('cr-button')!.click();
  }

  function getSiteList() {
    return testElement.shadowRoot!.querySelectorAll('.site-list .site-entry');
  }

  async function createPage() {
    document.body.innerHTML = window.trustedTypes!.emptyHTML;
    testElement = document.createElement('settings-unused-site-permissions');
    testElement.setModelUpdateDelayMsForTesting(0);
    Router.getInstance().navigateTo(routes.SITE_SETTINGS);
    document.body.appendChild(testElement);
    // Wait until the element has asked for the list of revoked permissions
    // that will be shown for review.
    await browserProxy.whenCalled('getRevokedUnusedSitePermissionsList');
    flush();
  }

  setup(async function() {
    browserProxy = new TestSafetyHubBrowserProxy();
    browserProxy.setUnusedSitePermissions(mockData);
    SafetyHubBrowserProxyImpl.setInstance(browserProxy);
    metricsBrowserProxy = new TestMetricsBrowserProxy();
    MetricsBrowserProxyImpl.setInstance(metricsBrowserProxy);
    resetRouterForTesting();
    await createPage();
    // Clear the metrics that were recorded as part of the initial creation of
    // the page.
    metricsBrowserProxy.reset();
    assertInitialUi();
  });

  test('Capture metrics on visit', async function() {
    await createPage();
    const result = await metricsBrowserProxy.whenCalled(
        'recordSafetyCheckUnusedSitePermissionsModuleInteractionsHistogram');
    assertEquals(
        SafetyCheckUnusedSitePermissionsModuleInteractions.OPEN_REVIEW_UI,
        result);
  });

  test('Unused Site Permission strings', function() {
    const siteList = getSiteList();
    assertEquals(4, siteList.length);

    // Check that the text describing the permissions is correct.
    assertEquals(
        mockData[0]!.origin,
        siteList[0]!.querySelector(
                        '.site-representation')!.textContent!.trim());
    assertEquals(
        'Removed location',
        siteList[0]!.querySelector('.secondary')!.textContent!.trim());

    assertEquals(
        mockData[1]!.origin,
        siteList[1]!.querySelector(
                        '.site-representation')!.textContent!.trim());
    assertEquals(
        'Removed location, microphone',
        siteList[1]!.querySelector('.secondary')!.textContent!.trim());

    assertEquals(
        mockData[2]!.origin,
        siteList[2]!.querySelector(
                        '.site-representation')!.textContent!.trim());
    assertEquals(
        'Removed location, microphone, camera',
        siteList[2]!.querySelector('.secondary')!.textContent!.trim());

    assertEquals(
        mockData[3]!.origin,
        siteList[3]!.querySelector(
                        '.site-representation')!.textContent!.trim());
    assertEquals(
        'Removed location, microphone, and 2 more',
        siteList[3]!.querySelector('.secondary')!.textContent!.trim());
  });

  test('Collapsible List', async function() {
    const expandButton =
        testElement.shadowRoot!.querySelector('cr-expand-button');
    assertTrue(!!expandButton);

    const unusedSitePermissionList =
        testElement.shadowRoot!.querySelector('cr-collapse');
    assertTrue(!!unusedSitePermissionList);

    // Button and list start out expanded.
    assertTrue(expandButton.expanded);
    assertTrue(unusedSitePermissionList.opened);

    // User collapses the list.
    expandButton.click();
    await expandButton.updateComplete;

    // Button and list are collapsed.
    assertFalse(expandButton.expanded);
    assertFalse(unusedSitePermissionList.opened);

    const result = await metricsBrowserProxy.whenCalled(
        'recordSafetyCheckUnusedSitePermissionsModuleInteractionsHistogram');
    assertEquals(
        SafetyCheckUnusedSitePermissionsModuleInteractions.MINIMIZE, result);

    // User expands the list.
    expandButton.click();
    await expandButton.updateComplete;

    // Button and list are expanded.
    assertTrue(expandButton.expanded);
    assertTrue(unusedSitePermissionList.opened);
  });

  test('Allow Again Click', async function() {
    const siteList = getSiteList();
    siteList[0]!.querySelector('cr-icon-button')!.click();

    assertAnimation([true, false, false, false]);
    // Ensure the browser proxy call is done.
    const expectedOrigin =
        siteList[0]!.querySelector('.site-representation')!.textContent!.trim();
    const [origin] =
        await browserProxy.whenCalled('allowPermissionsAgainForUnusedSite');
    assertEquals(origin, expectedOrigin);
  });

  test('Undo Allow Again', async function() {
    for (const [i, site] of getSiteList().entries()) {
      browserProxy.resetResolver('undoAllowPermissionsAgainForUnusedSite');
      site!.querySelector('cr-icon-button')!.click();
      const expectedAnimation = [false, false, false, false];
      expectedAnimation[i] = true;
      const expectedOrigin =
          site!.querySelector('.site-representation')!.textContent!.trim();

      assertAnimation(expectedAnimation);
      // Ensure the toast behaves correctly.
      const expectedToastText = testElement.i18n(
          'safetyCheckUnusedSitePermissionsToastLabel', expectedOrigin);
      assertToast(true, expectedToastText);
      // Ensure proxy call for undo is sent correctly.
      clickUndo();
      const [unusedSitePermissions] = await browserProxy.whenCalled(
          'undoAllowPermissionsAgainForUnusedSite');
      assertEquals(unusedSitePermissions.origin, expectedOrigin);
      assertDeepEquals(
          unusedSitePermissions.permissions, mockData[i]!.permissions);
      // UI should be back to its initial state.
      webUIListenerCallback(
          SafetyHubEvent.UNUSED_PERMISSIONS_MAYBE_CHANGED, mockData);
      flush();
      assertInitialUi();
    }
  });

  test('Undo Allow Again via Ctrl+Z', async function() {
    for (const [i, site] of getSiteList().entries()) {
      assertTrue(!!site);
      browserProxy.resetResolver('undoAllowPermissionsAgainForUnusedSite');
      const allowAgainButton = site.querySelector('cr-icon-button');
      assertTrue(!!allowAgainButton);
      allowAgainButton.click();
      const expectedAnimation = [false, false, false, false];
      expectedAnimation[i] = true;
      const expectedOrigin =
          site!.querySelector('.site-representation')!.textContent!.trim();

      assertAnimation(expectedAnimation);
      // Ensure the toast behaves correctly.
      const expectedToastText = testElement.i18n(
          'safetyCheckUnusedSitePermissionsToastLabel', expectedOrigin);
      assertToast(true, expectedToastText);
      // Ensure proxy call for undo is sent correctly after pressing Ctrl+Z.
      keyDownOn(document.documentElement, 0, isMac ? 'meta' : 'ctrl', 'z');
      const [unusedSitePermissions] = await browserProxy.whenCalled(
          'undoAllowPermissionsAgainForUnusedSite');
      assertEquals(unusedSitePermissions.origin, expectedOrigin);
      assertDeepEquals(
          unusedSitePermissions.permissions, mockData[i]!.permissions);
      // UI should be back to its initial state.
      webUIListenerCallback(
          SafetyHubEvent.UNUSED_PERMISSIONS_MAYBE_CHANGED, mockData);
      flush();
      assertInitialUi();
    }
  });

  test('Got It Click', async function() {
    clickGotIt();
    await flushTasks();

    // Ensure the browser proxy call is done.
    await browserProxy.whenCalled(
        'acknowledgeRevokedUnusedSitePermissionsList');
  });

  test('Undo Got It', async function() {
    clickGotIt();
    // Ensure the toast behaves correctly.
    const expectedToastText =
        await PluralStringProxyImpl.getInstance().getPluralString(
            'safetyCheckUnusedSitePermissionsToastBulkLabel', mockData.length);
    assertToast(true, expectedToastText);
    // Ensure proxy call is sent correctly for undo.
    clickUndo();
    const [unusedSitePermissionsList] = await browserProxy.whenCalled(
        'undoAcknowledgeRevokedUnusedSitePermissionsList');
    assertEqualsMockData(unusedSitePermissionsList);
    // UI should be back to its initial state.
    webUIListenerCallback(
        SafetyHubEvent.UNUSED_PERMISSIONS_MAYBE_CHANGED, mockData);
    assertInitialUi();
  });

  test('Got It Toast Strings', async function() {
    // Check plural version of the string.
    clickGotIt();
    await flushTasks();
    const expectedPluralToastText =
        await PluralStringProxyImpl.getInstance().getPluralString(
            'safetyCheckUnusedSitePermissionsToastBulkLabel', mockData.length);
    assertToast(true, expectedPluralToastText);

    // Check singular version of the string.
    const oneElementMockData = mockData.slice(0, 1);
    browserProxy.setUnusedSitePermissions(oneElementMockData);
    await createPage();
    assertToast(false);
    clickGotIt();
    await flushTasks();
    const expectedSingularToastText =
        await PluralStringProxyImpl.getInstance().getPluralString(
            'safetyCheckUnusedSitePermissionsToastBulkLabel', 1);
    assertToast(true, expectedSingularToastText);
  });

  test('Allow again record metrics', async function() {
    const siteList = getSiteList();
    siteList[0]!.querySelector('cr-icon-button')!.click();
    const result = await metricsBrowserProxy.whenCalled(
        'recordSafetyCheckUnusedSitePermissionsModuleInteractionsHistogram');
    assertEquals(
        SafetyCheckUnusedSitePermissionsModuleInteractions.ALLOW_AGAIN, result);
  });

  test('Undo allow again record metrics', async function() {
    const siteList = getSiteList();
    siteList[0]!.querySelector('cr-icon-button')!.click();
    // Reset the action captured by clicking the block button.
    metricsBrowserProxy.resetResolver(
        'recordSafetyCheckUnusedSitePermissionsModuleInteractionsHistogram');
    clickUndo();
    const result = await metricsBrowserProxy.whenCalled(
        'recordSafetyCheckUnusedSitePermissionsModuleInteractionsHistogram');
    assertEquals(
        SafetyCheckUnusedSitePermissionsModuleInteractions.UNDO_ALLOW_AGAIN,
        result);
  });

  test('Got it record metrics', async function() {
    clickGotIt();
    const result = await metricsBrowserProxy.whenCalled(
        'recordSafetyCheckUnusedSitePermissionsModuleInteractionsHistogram');
    assertEquals(
        SafetyCheckUnusedSitePermissionsModuleInteractions.ACKNOWLEDGE_ALL,
        result);
  });

  test('Undo got it record metrics', async function() {
    clickGotIt();
    // Reset the action captured by clicking the got it button.
    metricsBrowserProxy.resetResolver(
        'recordSafetyCheckUnusedSitePermissionsModuleInteractionsHistogram');
    clickUndo();
    const result = await metricsBrowserProxy.whenCalled(
        'recordSafetyCheckUnusedSitePermissionsModuleInteractionsHistogram');
    assertEquals(
        SafetyCheckUnusedSitePermissionsModuleInteractions.UNDO_ACKNOWLEDGE_ALL,
        result);
  });

  test('Review list size record metrics', async function() {
    browserProxy.setUnusedSitePermissions(mockData);
    await createPage();
    const resultNumSites = await metricsBrowserProxy.whenCalled(
        'recordSafetyCheckUnusedSitePermissionsListCountHistogram');
    assertEquals(mockData.length, resultNumSites);

    metricsBrowserProxy.resetResolver(
        'recordSafetyCheckUnusedSitePermissionsListCountHistogram');

    browserProxy.setUnusedSitePermissions([]);
    await createPage();
    const resultEmpty = await metricsBrowserProxy.whenCalled(
        'recordSafetyCheckUnusedSitePermissionsListCountHistogram');
    assertEquals(0, resultEmpty);
  });
});