chromium/chrome/test/data/webui/chromeos/settings/os_privacy_page/privacy_hub_microphone_subpage_test.ts

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

import 'chrome://os-settings/lazy_load.js';

import {MediaDevicesProxy, PrivacyHubBrowserProxyImpl, SettingsPrivacyHubMicrophoneSubpage} from 'chrome://os-settings/lazy_load.js';
import {appPermissionHandlerMojom, CrLinkRowElement, CrToggleElement, PaperTooltipElement, PrivacyHubSensorSubpageUserAction, Router, setAppPermissionProviderForTesting} from 'chrome://os-settings/os_settings.js';
import {PermissionType, TriState} from 'chrome://resources/cr_components/app_management/app_management.mojom-webui.js';
import {webUIListenerCallback} from 'chrome://resources/js/cr.js';
import {DomRepeat, flush} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {assertEquals, assertFalse, assertNull, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {flushTasks, waitAfterNextRender} from 'chrome://webui-test/polymer_test_util.js';
import {isVisible} from 'chrome://webui-test/test_util.js';

import {FakeMediaDevices} from '../fake_media_devices.js';
import {FakeMetricsPrivate} from '../fake_metrics_private.js';

import {FakeAppPermissionHandler} from './fake_app_permission_handler.js';
import {createApp, createFakeMetricsPrivate, getSystemServicePermissionText, getSystemServicesFromSubpage} from './privacy_hub_app_permission_test_util.js';
import {TestPrivacyHubBrowserProxy} from './test_privacy_hub_browser_proxy.js';

type App = appPermissionHandlerMojom.App;

suite('<settings-privacy-hub-microphone-subpage>', () => {
  let fakeHandler: FakeAppPermissionHandler;
  let metrics: FakeMetricsPrivate;
  let privacyHubMicrophoneSubpage: SettingsPrivacyHubMicrophoneSubpage;
  let privacyHubBrowserProxy: TestPrivacyHubBrowserProxy;
  let mediaDevices: FakeMediaDevices;

  setup(() => {
    fakeHandler = new FakeAppPermissionHandler();
    setAppPermissionProviderForTesting(fakeHandler);

    metrics = createFakeMetricsPrivate();

    privacyHubBrowserProxy = new TestPrivacyHubBrowserProxy();
    PrivacyHubBrowserProxyImpl.setInstanceForTesting(privacyHubBrowserProxy);

    mediaDevices = new FakeMediaDevices();
    MediaDevicesProxy.setMediaDevicesForTesting(mediaDevices);

    privacyHubMicrophoneSubpage =
        document.createElement('settings-privacy-hub-microphone-subpage');
    const prefs = {
      'ash': {
        'user': {
          'microphone_allowed': {
            value: true,
          },
        },
      },
    };
    privacyHubMicrophoneSubpage.prefs = prefs;
    document.body.appendChild(privacyHubMicrophoneSubpage);
    flush();
  });

  teardown(() => {
    privacyHubMicrophoneSubpage.remove();
    Router.getInstance().resetRouteForTesting();
  });

  function getMicrophoneCrToggle(): CrToggleElement {
    const crToggle =
        privacyHubMicrophoneSubpage.shadowRoot!.querySelector<CrToggleElement>(
            '#microphoneToggle');
    assertTrue(!!crToggle);
    return crToggle;
  }

  function getMicrophoneTooltip(): PaperTooltipElement {
    const tooltip =
        privacyHubMicrophoneSubpage.shadowRoot!
            .querySelector<PaperTooltipElement>('#microphoneToggleTooltip');
    assertTrue(!!tooltip);
    return tooltip;
  }

  function getOnOffText(): string {
    return privacyHubMicrophoneSubpage.shadowRoot!.querySelector('#onOffText')!
        .textContent!.trim();
  }

  function getOnOffSubtext(): string {
    return privacyHubMicrophoneSubpage.shadowRoot!
        .querySelector('#onOffSubtext')!.textContent!.trim();
  }

  function isMicrophoneListSectionVisible(): boolean {
    return isVisible(privacyHubMicrophoneSubpage.shadowRoot!.querySelector(
        '#microphoneListSection'));
  }

  function getNoMicrophoneTextElement(): HTMLDivElement|null {
    return privacyHubMicrophoneSubpage.shadowRoot!.querySelector(
        '#noMicrophoneText');
  }

  function getMicrophoneList(): DomRepeat|null {
    return privacyHubMicrophoneSubpage.shadowRoot!.querySelector<DomRepeat>(
        '#microphoneList');
  }

  test('Microphone access is allowed by default', () => {
    assertTrue(getMicrophoneCrToggle().checked);
  });

  function isBlockedSuffixDisplayedAfterMicrophoneName(): boolean {
    return isVisible(privacyHubMicrophoneSubpage.shadowRoot!.querySelector(
        '#microphoneNameWithBlockedSuffix'));
  }

  test('Microphone section view when microphone access is enabled', () => {
    const microphoneToggle = getMicrophoneCrToggle();

    assertEquals(
        microphoneToggle.checked,
        privacyHubMicrophoneSubpage.prefs.ash.user.microphone_allowed.value);
    assertEquals(privacyHubMicrophoneSubpage.i18n('deviceOn'), getOnOffText());
    assertEquals(
        privacyHubMicrophoneSubpage.i18n(
            'privacyHubMicrophoneSubpageMicrophoneToggleSubtext'),
        getOnOffSubtext());
    assertTrue(isMicrophoneListSectionVisible());
    assertFalse(isBlockedSuffixDisplayedAfterMicrophoneName());
  });

  test(
      'Microphone section view when microphone access is disabled',
      async () => {
        // Adding a microphone, otherwise clicking on the toggle will be no-op.
        mediaDevices.addDevice('audioinput', 'Fake Microphone');
        await waitAfterNextRender(privacyHubMicrophoneSubpage);

        const microphoneToggle = getMicrophoneCrToggle();

        // Disabled microphone access.
        microphoneToggle.click();
        await waitAfterNextRender(privacyHubMicrophoneSubpage);

        assertEquals(
            microphoneToggle.checked,
            privacyHubMicrophoneSubpage.prefs.ash.user.microphone_allowed
                .value);
        assertEquals(
            privacyHubMicrophoneSubpage.i18n('deviceOff'), getOnOffText());
        assertEquals(
            privacyHubMicrophoneSubpage.i18n(
                'privacyHubMicrophoneAccessBlockedText'),
            getOnOffSubtext());
        assertTrue(isMicrophoneListSectionVisible());
        assertTrue(isBlockedSuffixDisplayedAfterMicrophoneName());
        assertEquals(
            privacyHubMicrophoneSubpage.i18n(
                'privacyHubSensorNameWithBlockedSuffix', 'Fake Microphone'),
            privacyHubMicrophoneSubpage.shadowRoot!
                .querySelector<HTMLDivElement>(
                    '#microphoneNameWithBlockedSuffix')!.innerText.trim());
      });

  test('Repeatedly toggle microphone access', async () => {
    // Adding a microphone, otherwise clicking on the toggle will be no-op.
    mediaDevices.addDevice('audioinput', 'Fake Microphone');
    await waitAfterNextRender(privacyHubMicrophoneSubpage);

    const microphoneToggle = getMicrophoneCrToggle();

    for (let i = 0; i < 3; i++) {
      // Toggle microphone access.
      microphoneToggle.click();
      await waitAfterNextRender(privacyHubMicrophoneSubpage);

      assertEquals(
          microphoneToggle.checked,
          privacyHubMicrophoneSubpage.prefs.ash.user.microphone_allowed.value);
      assertEquals(
          i + 1,
          metrics.countMetricValue(
              'ChromeOS.PrivacyHub.MicrophoneSubpage.UserAction',
              PrivacyHubSensorSubpageUserAction.SYSTEM_ACCESS_CHANGED));
    }
  });

  function isMicrophoneRowActionable(): boolean {
    const actionableAttribute =
        privacyHubMicrophoneSubpage.shadowRoot!
            .querySelector('#accessStatusRow')!.getAttribute('actionable');
    return actionableAttribute === '';
  }

  test(
      'Clicking toggle is no-op when accessStatusRow is not actionable',
      async () => {
        const microphoneToggle = getMicrophoneCrToggle();
        assertTrue(microphoneToggle.checked);

        assertFalse(isMicrophoneRowActionable());
        microphoneToggle.click();
        assertTrue(microphoneToggle.checked);

        // Add a microphone to make accessStatusRow actionable.
        mediaDevices.addDevice('audioinput', 'Fake Microphone');
        await flushTasks();
        assertTrue(isMicrophoneRowActionable());
        microphoneToggle.click();
        assertFalse(microphoneToggle.checked);

        // Activate microphone hardware toggle to make the accessStatusRow not
        // actionable.
        webUIListenerCallback('microphone-hardware-toggle-changed', true);
        flush();
        assertFalse(isMicrophoneRowActionable());
        microphoneToggle.click();
        assertFalse(microphoneToggle.checked);
      });

  test('No microphone connected by default', () => {
    assertNull(getMicrophoneList());
    assertTrue(!!getNoMicrophoneTextElement());
    assertEquals(
        privacyHubMicrophoneSubpage.i18n('noMicrophoneConnectedText'),
        getNoMicrophoneTextElement()!.textContent!.trim());
  });

  test(
      'Toggle disabled but no tooltip displayed when no microphone connected',
      () => {
        assertTrue(getMicrophoneCrToggle()!.disabled);
        assertTrue(getMicrophoneTooltip()!.hidden);
      });

  test(
      'Toggle enabled when at least one microphone connected but no tooltip',
      async () => {
        // Add a microphone.
        mediaDevices.addDevice('audioinput', 'Fake Microphone');
        await waitAfterNextRender(privacyHubMicrophoneSubpage);

        assertFalse(getMicrophoneCrToggle()!.disabled);
        assertTrue(getMicrophoneTooltip()!.hidden);
        assertNull(getNoMicrophoneTextElement());
      });

  test(
      'Toggle disabled and a tooltip displayed when hardware switch is active',
      async () => {
        // Add a microphone.
        mediaDevices.addDevice('audioinput', 'Fake Microphone');
        await waitAfterNextRender(privacyHubMicrophoneSubpage);

        assertFalse(getMicrophoneCrToggle()!.disabled);
        assertTrue(getMicrophoneTooltip()!.hidden);

        // Activate the hw toggle.
        webUIListenerCallback('microphone-hardware-toggle-changed', true);
        await waitAfterNextRender(privacyHubMicrophoneSubpage);

        assertTrue(getMicrophoneCrToggle()!.disabled);
        assertFalse(getMicrophoneTooltip()!.hidden);
      });

  test(
      'Microphone list updated when a microphone is added or removed',
      async () => {
        const testDevices = [
          {
            device: {
              kind: 'audiooutput',
              label: 'Fake Speaker 1',
            },
          },
          {
            device: {
              kind: 'videoinput',
              label: 'Fake Camera 1',
            },
          },
          {
            device: {
              kind: 'audioinput',
              label: 'Fake Microphone 1',
            },
          },
          {
            device: {
              kind: 'videoinput',
              label: 'Fake Camera 2',
            },
          },
          {
            device: {
              kind: 'audiooutput',
              label: 'Fake Speaker 2',
            },
          },
          {
            device: {
              kind: 'audioinput',
              label: 'Fake Microphone 2',
            },
          },
        ];

        let microphoneCount = 0;

        // Adding a media device in each iteration.
        for (const test of testDevices) {
          mediaDevices.addDevice(test.device.kind, test.device.label);
          await waitAfterNextRender(privacyHubMicrophoneSubpage);

          if (test.device.kind === 'audioinput') {
            microphoneCount++;
          }

          const microphoneList = getMicrophoneList();
          if (microphoneCount) {
            assertTrue(!!microphoneList);
            assertEquals(microphoneCount, microphoneList.items!.length);
          } else {
            assertNull(microphoneList);
          }
        }

        // Removing the most recently added media device in each iteration.
        for (const test of testDevices.reverse()) {
          mediaDevices.popDevice();
          await waitAfterNextRender(privacyHubMicrophoneSubpage);

          if (test.device.kind === 'audioinput') {
            microphoneCount--;
          }

          const microphoneList = getMicrophoneList();
          if (microphoneCount) {
            assertTrue(!!microphoneList);
            assertEquals(microphoneCount, microphoneList.items!.length);
          } else {
            assertNull(microphoneList);
          }
        }
      });

  function getNoAppHasAccessTextSection(): HTMLDivElement|null {
    return privacyHubMicrophoneSubpage.shadowRoot!.querySelector(
        '#noAppHasAccessText');
  }

  function getAppList(): DomRepeat|null {
    return privacyHubMicrophoneSubpage.shadowRoot!.querySelector('#appList');
  }

  test('Apps section when microphone allowed', () => {
    assertEquals(
        privacyHubMicrophoneSubpage.i18n('privacyHubAppsSectionTitle'),
        privacyHubMicrophoneSubpage.shadowRoot!
            .querySelector('#appsSectionTitle')!.textContent!.trim());
    assertTrue(!!getAppList());
    assertNull(getNoAppHasAccessTextSection());
  });

  test('Apps section when microphone not allowed', async () => {
    mediaDevices.addDevice('audioinput', 'Fake Microphone');
    await waitAfterNextRender(privacyHubMicrophoneSubpage);
    // Disable microphone access.
    getMicrophoneCrToggle().click();
    await waitAfterNextRender(privacyHubMicrophoneSubpage);

    assertNull(getAppList());
    assertTrue(!!getNoAppHasAccessTextSection());
    assertEquals(
        privacyHubMicrophoneSubpage.i18n('noAppCanUseMicText'),
        getNoAppHasAccessTextSection()!.textContent!.trim());
  });

  function initializeObserver(): Promise<void> {
    return fakeHandler.whenCalled('addObserver');
  }

  function simulateAppUpdate(app: App): void {
    fakeHandler.getObserverRemote().onAppUpdated(app);
  }

  function simulateAppRemoval(id: string): void {
    fakeHandler.getObserverRemote().onAppRemoved(id);
  }

  test('AppList displays all apps with microphone permission', async () => {
    const app1 = createApp(
        'app1_id', 'app1_name', PermissionType.kMicrophone, TriState.kAllow);
    const app2 = createApp(
        'app2_id', 'app2_name', PermissionType.kCamera, TriState.kAllow);
    const app3 = createApp(
        'app3_id', 'app3_name', PermissionType.kMicrophone, TriState.kAsk);

    await initializeObserver();
    simulateAppUpdate(app1);
    simulateAppUpdate(app2);
    simulateAppUpdate(app3);
    await flushTasks();

    assertEquals(2, getAppList()!.items!.length);
  });

  test('Removed app are removed from appList', async () => {
    const app1 = createApp(
        'app1_id', 'app1_name', PermissionType.kMicrophone, TriState.kAllow);
    const app2 = createApp(
        'app2_id', 'app2_name', PermissionType.kCamera, TriState.kAllow);

    await initializeObserver();
    simulateAppUpdate(app1);
    simulateAppUpdate(app2);
    await flushTasks();

    assertEquals(1, getAppList()!.items!.length);

    simulateAppRemoval(app2.id);
    await flushTasks();

    assertEquals(1, getAppList()!.items!.length);

    simulateAppRemoval(app1.id);
    await flushTasks();

    assertEquals(0, getAppList()!.items!.length);
  });

  function getManagePermissionsInChromeRow(): CrLinkRowElement {
    const managePermissionsInChromeRow =
        privacyHubMicrophoneSubpage.shadowRoot!.querySelector<CrLinkRowElement>(
            '#managePermissionsInChromeRow');
    assertTrue(!!managePermissionsInChromeRow);
    return managePermissionsInChromeRow;
  }

  function getNoWebsiteHasAccessTextRow(): HTMLDivElement {
    const noWebsiteHasAccessTextRow =
        privacyHubMicrophoneSubpage.shadowRoot!.querySelector<HTMLDivElement>(
            '#noWebsiteHasAccessText');
    assertTrue(!!noWebsiteHasAccessTextRow);
    return noWebsiteHasAccessTextRow;
  }

  test('Websites section texts', async () => {
    assertEquals(
        privacyHubMicrophoneSubpage.i18n('websitesSectionTitle'),
        privacyHubMicrophoneSubpage.shadowRoot!
            .querySelector('#websitesSectionTitle')!.textContent!.trim());

    assertEquals(
        privacyHubMicrophoneSubpage.i18n('manageMicPermissionsInChromeText'),
        getManagePermissionsInChromeRow().label);

    assertEquals(
        privacyHubMicrophoneSubpage.i18n('noWebsiteCanUseMicText'),
        getNoWebsiteHasAccessTextRow().textContent!.trim());
  });

  test('Websites section when microphone allowed', async () => {
    assertFalse(getManagePermissionsInChromeRow().hidden);
    assertTrue(getNoWebsiteHasAccessTextRow().hidden);
  });

  test('Websites section when microphone not allowed', async () => {
    mediaDevices.addDevice('audioinput', 'Fake Microphone');
    await waitAfterNextRender(privacyHubMicrophoneSubpage);

    // Toggle microphone access.
    getMicrophoneCrToggle().click();
    await waitAfterNextRender(privacyHubMicrophoneSubpage);

    assertTrue(getManagePermissionsInChromeRow().hidden);
    assertFalse(getNoWebsiteHasAccessTextRow().hidden);
  });

  test('Website section metric recorded when clicked', () => {
    assertEquals(
        0,
        metrics.countMetricValue(
            'ChromeOS.PrivacyHub.MicrophoneSubpage.UserAction',
            PrivacyHubSensorSubpageUserAction.WEBSITE_PERMISSION_LINK_CLICKED));

    getManagePermissionsInChromeRow().click();

    assertEquals(
        1,
        metrics.countMetricValue(
            'ChromeOS.PrivacyHub.MicrophoneSubpage.UserAction',
            PrivacyHubSensorSubpageUserAction.WEBSITE_PERMISSION_LINK_CLICKED));
  });

  test(
      'Clicking Chrome row opens Chrome browser microphone permission settings',
      async () => {
        assertEquals(
            PermissionType.kUnknown,
            fakeHandler.getLastOpenedBrowserPermissionSettingsType());

        getManagePermissionsInChromeRow()!.click();
        await fakeHandler.whenCalled('openBrowserPermissionSettings');

        assertEquals(
            PermissionType.kMicrophone,
            fakeHandler.getLastOpenedBrowserPermissionSettingsType());
      });

  test('System services section when microphone is allowed', async () => {
    assertEquals(
        privacyHubMicrophoneSubpage.i18n(
            'privacyHubSystemServicesSectionTitle'),
        privacyHubMicrophoneSubpage.shadowRoot!
            .querySelector('#systemServicesSectionTitle')!.textContent!.trim());

    await flushTasks();
    const systemServices =
        getSystemServicesFromSubpage(privacyHubMicrophoneSubpage);
    assertEquals(1, systemServices.length);
    assertEquals(
        privacyHubMicrophoneSubpage.i18n('privacyHubSystemServicesAllowedText'),
        getSystemServicePermissionText(systemServices[0]!));
  });

  test('System services section when microphone is not allowed', async () => {
    mediaDevices.addDevice('audioinput', 'Fake Microphone');
    await flushTasks();
    // Toggle microphone access.
    getMicrophoneCrToggle().click();
    flush();

    const systemServices =
        getSystemServicesFromSubpage(privacyHubMicrophoneSubpage);
    assertEquals(1, systemServices.length);
    assertEquals(
        privacyHubMicrophoneSubpage.i18n('privacyHubSystemServicesBlockedText'),
        getSystemServicePermissionText(systemServices[0]!));
  });
});