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

// Copyright 2016 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 {assert} from 'chrome://resources/js/assert.js';
import {webUIListenerCallback} from 'chrome://resources/js/cr.js';
import type {StorageAccessSiteException, AppProtocolEntry, ChooserType, HandlerEntry, OriginFileSystemGrants, ProtocolEntry, RawChooserException, RawSiteException, RecentSitePermissions, SiteGroup, SiteSettingsPrefsBrowserProxy, ZoomLevelEntry} from 'chrome://settings/lazy_load.js';
import {ContentSetting, ContentSettingsTypes, SiteSettingSource} from 'chrome://settings/lazy_load.js';
import {TestBrowserProxy} from 'chrome://webui-test/test_browser_proxy.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';

import type {SiteSettingsPref} from './test_util.js';
import {createOriginInfo, createSiteGroup,createSiteSettingsPrefs, getContentSettingsTypeFromChooserType} from './test_util.js';
// clang-format on

/**
 * A test version of SiteSettingsPrefsBrowserProxy. Provides helper methods
 * for allowing tests to know when a method was called, as well as
 * specifying mock responses.
 */
export class TestSiteSettingsPrefsBrowserProxy extends TestBrowserProxy
    implements SiteSettingsPrefsBrowserProxy {
  private hasIncognito_: boolean = false;
  private categoryList_: ContentSettingsTypes[];
  private prefs_: SiteSettingsPref;
  private zoomList_: ZoomLevelEntry[] = [];
  private appAllowedProtocolHandlers_: AppProtocolEntry[] = [];
  private appDisallowedProtocolHandlers_: AppProtocolEntry[] = [];
  private protocolHandlers_: ProtocolEntry[] = [];
  private ignoredProtocols_: HandlerEntry[] = [];
  private isOriginValid_: boolean = true;
  private isPatternValidForType_: boolean = true;
  private recentSitePermissions_: RecentSitePermissions[] = [];
  private fileSystemGrantsList_: OriginFileSystemGrants[] = [];
  private storageAccessExceptionList_: StorageAccessSiteException[] = [];

  constructor() {
    super([
      'fetchBlockAutoplayStatus',
      'fetchZoomLevels',
      'getAllSites',
      'getCategoryList',
      'getChooserExceptionList',
      'getDefaultValueForContentType',
      'getFormattedBytes',
      'getExceptionList',
      'getStorageAccessExceptionList',
      'getOriginPermissions',
      'isOriginValid',
      'isPatternValidForType',
      'observeAppProtocolHandlers',
      'observeProtocolHandlers',
      'observeProtocolHandlersEnabledState',
      'removeAppAllowedHandler',
      'removeAppDisallowedHandler',
      'removeIgnoredHandler',
      'removeProtocolHandler',
      'removeZoomLevel',
      'resetCategoryPermissionForPattern',
      'resetChooserExceptionForSite',
      'setCategoryPermissionForPattern',
      'setDefaultValueForContentType',
      'setOriginPermissions',
      'setProtocolDefault',
      'setProtocolHandlerDefault',
      'updateIncognitoStatus',
      'clearSiteGroupDataAndCookies',
      'clearUnpartitionedOriginDataAndCookies',
      'clearPartitionedOriginDataAndCookies',
      'recordAction',
      'getRecentSitePermissions',
      'getRwsMembershipLabel',
      'getNumCookiesString',
      'getSystemDeniedPermissions',
      'openSystemPermissionSettings',
      'getExtensionName',
      'getFileSystemGrants',
      'revokeFileSystemGrant',
      'revokeFileSystemGrants',
    ]);


    this.categoryList_ = [
      ContentSettingsTypes.ADS,
      ContentSettingsTypes.AR,
      ContentSettingsTypes.AUTO_PICTURE_IN_PICTURE,
      ContentSettingsTypes.AUTOMATIC_DOWNLOADS,
      ContentSettingsTypes.BACKGROUND_SYNC,
      ContentSettingsTypes.BLUETOOTH_DEVICES,
      ContentSettingsTypes.BLUETOOTH_SCANNING,
      ContentSettingsTypes.CAMERA,
      ContentSettingsTypes.CLIPBOARD,
      ContentSettingsTypes.FEDERATED_IDENTITY_API,
      ContentSettingsTypes.FILE_SYSTEM_WRITE,
      ContentSettingsTypes.GEOLOCATION,
      ContentSettingsTypes.HID_DEVICES,
      ContentSettingsTypes.IDLE_DETECTION,
      ContentSettingsTypes.IMAGES,
      ContentSettingsTypes.JAVASCRIPT,
      ContentSettingsTypes.JAVASCRIPT_JIT,
      ContentSettingsTypes.LOCAL_FONTS,
      ContentSettingsTypes.MIC,
      ContentSettingsTypes.MIDI_DEVICES,
      ContentSettingsTypes.MIXEDSCRIPT,
      ContentSettingsTypes.NOTIFICATIONS,
      ContentSettingsTypes.PAYMENT_HANDLER,
      ContentSettingsTypes.POPUPS,
      ContentSettingsTypes.PROTECTED_CONTENT,
      ContentSettingsTypes.SENSORS,
      ContentSettingsTypes.SERIAL_PORTS,
      ContentSettingsTypes.SOUND,
      ContentSettingsTypes.USB_DEVICES,
      ContentSettingsTypes.VR,
      ContentSettingsTypes.WINDOW_MANAGEMENT,
    ];

    if (loadTimeData.getBoolean('enableWebPrintingContentSetting')) {
      this.categoryList_.push(ContentSettingsTypes.WEB_PRINTING);
    }

    if (loadTimeData.getBoolean('enableAutomaticFullscreenContentSetting')) {
      this.categoryList_.push(ContentSettingsTypes.AUTOMATIC_FULLSCREEN);
    }

    if (loadTimeData.getBoolean('capturedSurfaceControlEnabled')) {
      this.categoryList_.push(ContentSettingsTypes.CAPTURED_SURFACE_CONTROL);
    }

    this.prefs_ = createSiteSettingsPrefs([], [], []);
  }

  /**
   * Test/fake implementation for {@link getCategoryList}.
   * @param origin the origin for the list of visible permissions.
   */
  getCategoryListForTest(_origin: string): ContentSettingsTypes[] {
    return this.categoryList_;
  }

  setCategoryList(list: ContentSettingsTypes[]) {
    this.categoryList_ = list;
  }

  /**
   * Pretends an incognito session started or ended.
   * @param hasIncognito True for session started.
   */
  setIncognito(hasIncognito: boolean) {
    this.hasIncognito_ = hasIncognito;
    webUIListenerCallback('onIncognitoStatusChanged', hasIncognito);
  }

  /**
   * Sets the prefs to use when testing.
   */
  setPrefs(prefs: SiteSettingsPref) {
    this.prefs_ = prefs;

    // Notify all listeners that their data may be out of date.
    for (const type in prefs.defaults) {
      webUIListenerCallback('contentSettingCategoryChanged', type);
    }
    for (const type in this.prefs_.exceptions) {
      const exceptionList =
          this.prefs_.exceptions[type as ContentSettingsTypes];
      for (let i = 0; i < exceptionList.length; ++i) {
        webUIListenerCallback(
            'contentSettingSitePermissionChanged', type,
            exceptionList[i]!.origin, '');
      }
    }
    for (const type in this.prefs_.chooserExceptions) {
      const chooserExceptionList =
          this.prefs_.chooserExceptions[type as ContentSettingsTypes];
      for (let i = 0; i < chooserExceptionList.length; ++i) {
        webUIListenerCallback('contentSettingChooserPermissionChanged', type);
      }
    }
  }

  /**
   * Sets one exception for a given category, replacing any existing exceptions
   * for the same origin. Note this ignores embedding origins.
   * @param category The category the new exception belongs to.
   * @param newException The new preference to add/replace.
   */
  setSingleException(
      category: ContentSettingsTypes, newException: RawSiteException) {
    // Remove entries from the current prefs which have the same origin.
    const newPrefs = /** @type {!Array<RawSiteException>} */
        (this.prefs_.exceptions[category].filter(
            (categoryException: RawSiteException) => {
              return categoryException.origin !== newException.origin;
            }));
    newPrefs.push(newException);
    this.prefs_.exceptions[category] = newPrefs;

    webUIListenerCallback(
        'contentSettingSitePermissionChanged', category, newException.origin);
  }

  /**
   * Sets the prefs to use when testing.
   */
  setZoomList(list: ZoomLevelEntry[]) {
    this.zoomList_ = list;
  }

  /**
   * Sets the prefs to use when testing.
   */
  setProtocolHandlers(list: ProtocolEntry[]) {
    // Shallow copy of the passed-in array so mutation won't impact the source
    this.protocolHandlers_ = list.slice();
  }

  /**
   * Sets the prefs to use when testing.
   * @param list The web app protocol handlers list to set.
   */
  setAppAllowedProtocolHandlers(list: AppProtocolEntry[]) {
    // Shallow copy of the passed-in array so mutation won't impact the source
    this.appAllowedProtocolHandlers_ = list.slice();
  }

  /**
   * Sets the prefs to use when testing.
   * @param list The web app protocol handlers list to set.
   */
  setAppDisallowedProtocolHandlers(list: AppProtocolEntry[]) {
    // Shallow copy of the passed-in array so mutation won't impact the source
    this.appDisallowedProtocolHandlers_ = list.slice();
  }

  /**
   * Sets the prefs to use when testing.
   */
  setIgnoredProtocols(list: HandlerEntry[]) {
    // Shallow copy of the passed-in array so mutation won't impact the source
    this.ignoredProtocols_ = list.slice();
  }

  /** @override */
  setDefaultValueForContentType(contentType: string, defaultValue: string) {
    this.methodCalled(
        'setDefaultValueForContentType', [contentType, defaultValue]);
  }

  /** @override */
  setOriginPermissions(
      origin: string, category: ContentSettingsTypes|null,
      blanketSetting: ContentSetting) {
    const contentTypes =
        category ? [category] : this.getCategoryListForTest(origin);
    for (let i = 0; i < contentTypes.length; ++i) {
      const type = contentTypes[i]!;
      const exceptionList = this.prefs_.exceptions[type];
      for (let j = 0; j < exceptionList.length; ++j) {
        let effectiveSetting = blanketSetting;
        if (blanketSetting === ContentSetting.DEFAULT) {
          effectiveSetting = this.prefs_.defaults[type].setting;
          exceptionList[j]!.source = SiteSettingSource.DEFAULT;
        }
        exceptionList[j]!.setting = effectiveSetting;
      }
    }

    this.setPrefs(this.prefs_);
    this.methodCalled(
        'setOriginPermissions', [origin, category, blanketSetting]);
  }

  /** @override */
  getAllSites() {
    this.methodCalled('getAllSites');
    const contentTypes = this.getCategoryListForTest('https://example.com');
    const origins_set = new Set<string>();

    contentTypes.forEach((contentType) => {
      this.prefs_.exceptions[contentType].forEach(
          (exception: RawSiteException) => {
            if (exception.origin.includes('*')) {
              return;
            }
            origins_set.add(exception.origin);
          });
    });

    const origins_array = [...origins_set];
    const result: SiteGroup[] = [];
    origins_array.forEach((origin, index) => {
      // Functionality to get the eTLD+1 from an origin exists only on the
      // C++ side, so just do an (incorrect) approximate extraction here.
      const host = new URL(origin).host;
      let urlParts = host.split('.');
      urlParts = urlParts.slice(Math.max(urlParts.length - 2, 0));
      const etldPlus1Name = urlParts.join('.');

      const existing = result.find(siteGroup => {
        return siteGroup.etldPlus1 === etldPlus1Name;
      });

      const mockUsage = index * 100;

      // TODO(crbug.com/40106241): Add test where existing evaluates to
      // true.
      if (existing) {
        const originInfo = createOriginInfo(origin, {usage: mockUsage});
        existing.origins.push(originInfo);
      } else {
        const entry =
            createSiteGroup(etldPlus1Name, etldPlus1Name, [origin], mockUsage);
        result.push(entry);
      }
    });

    return Promise.resolve(result);
  }

  /** @override */
  getCategoryList(origin: string) {
    this.methodCalled('getCategoryList', origin);
    return Promise.resolve(this.getCategoryListForTest(origin));
  }

  /** @override */
  getFormattedBytes(numBytes: number) {
    this.methodCalled('getFormattedBytes', numBytes);
    return Promise.resolve(`${numBytes} B`);
  }

  /** @override */
  getDefaultValueForContentType(contentType: ContentSettingsTypes) {
    this.methodCalled('getDefaultValueForContentType', contentType);
    const pref = this.prefs_.defaults[contentType];
    assert(pref !== undefined, 'Pref is missing for ' + contentType);
    return Promise.resolve(pref);
  }

  /** @override */
  getExceptionList(contentType: ContentSettingsTypes) {
    // Defer |methodCalled| call so that |then| callback for the promise
    // returned from this method runs before the one for the promise returned
    // from |whenCalled| calls in tests.
    // TODO(b/297567461): Remove once the flaky test fixes in
    // https://chromium-review.googlesource.com/c/chromium/src/+/4124308 are
    // confirmed to no longer be needed.
    window.setTimeout(
        () => this.methodCalled('getExceptionList', contentType), 0);
    let pref = this.prefs_.exceptions[contentType];
    assert(pref !== undefined, 'Pref is missing for ' + contentType);

    if (this.hasIncognito_) {
      const incognitoElements = [];
      for (let i = 0; i < pref.length; ++i) {
        // Copy |pref[i]| to avoid changing the original |pref[i]|.
        const incognitoPref = Object.assign({}, pref[i]);
        incognitoElements.push(Object.assign(incognitoPref, {incognito: true}));
      }
      pref = pref.concat(incognitoElements);
    }

    return Promise.resolve(pref);
  }

  /** @override */
  getChooserExceptionList(chooserType: ChooserType) {
    // The UI uses the |chooserType| to retrieve the prefs for a chooser
    // permission, however the test stores the permissions with the setting
    // category, so we need to get the content settings type that pertains to
    // this chooser type.
    const setting = getContentSettingsTypeFromChooserType(chooserType);
    assert(
        setting != null,
        'ContentSettingsType mapping missing for ' + chooserType);

    // Create a deep copy of the pref so that the chooser-exception-list element
    // is able update the UI appropriately when incognito mode is toggled.
    const pref = /** @type {!Array<!RawChooserException>} */ (
        structuredClone(this.prefs_.chooserExceptions[setting!]));
    assert(pref !== undefined, 'Pref is missing for ' + chooserType);

    if (this.hasIncognito_) {
      for (let i = 0; i < pref.length; ++i) {
        const incognitoElements = [];
        for (let j = 0; j < pref[i]!.sites.length; ++j) {
          // Skip preferences that are not controlled by policy since opening an
          // incognito session does not automatically grant permission to
          // chooser exceptions that have been granted in the main session.
          if (pref[i]!.sites[j]!.source !== SiteSettingSource.POLICY) {
            continue;
          }

          // Copy |sites[i]| to avoid changing the original |sites[i]|.
          const incognitoSite = Object.assign({}, pref[i]!.sites[j]);
          incognitoElements.push(
              Object.assign(incognitoSite, {incognito: true}));
        }
        pref[i]!.sites.push(...incognitoElements);
      }
    }

    this.methodCalled('getChooserExceptionList', chooserType);
    return Promise.resolve(pref);
  }

  /** @override */
  isOriginValid(origin: string) {
    this.methodCalled('isOriginValid', origin);
    return Promise.resolve(this.isOriginValid_);
  }

  /**
   * Specify whether isOriginValid should succeed or fail.
   */
  setIsOriginValid(isValid: boolean) {
    this.isOriginValid_ = isValid;
  }

  /** @override */
  isPatternValidForType(pattern: string, category: ContentSettingsTypes) {
    this.methodCalled('isPatternValidForType', [pattern, category]);
    return Promise.resolve({
      isValid: this.isPatternValidForType_,
      reason: this.isPatternValidForType_ ? '' : 'pattern is invalid',
    });
  }

  /**
   * Specify whether isPatternValidForType should succeed or fail.
   */
  setIsPatternValidForType(isValid: boolean) {
    this.isPatternValidForType_ = isValid;
  }

  /** @override */
  resetCategoryPermissionForPattern(
      primaryPattern: string, secondaryPattern: string, contentType: string,
      incognito: boolean) {
    this.methodCalled(
        'resetCategoryPermissionForPattern',
        [primaryPattern, secondaryPattern, contentType, incognito]);
  }

  /** @override */
  resetChooserExceptionForSite(
      chooserType: ChooserType, origin: string, exception: Object) {
    this.methodCalled(
        'resetChooserExceptionForSite', [chooserType, origin, exception]);
  }

  /** @override */
  getOriginPermissions(origin: string, contentTypes: ContentSettingsTypes[]) {
    this.methodCalled('getOriginPermissions', [origin, contentTypes]);

    const exceptionList: RawSiteException[] = [];
    contentTypes.forEach(contentType => {
      let setting: ContentSetting|undefined;
      let source: SiteSettingSource|undefined;
      const isSet = this.prefs_.exceptions[contentType].some(
          (originPrefs: RawSiteException) => {
            if (originPrefs.origin === origin) {
              setting = originPrefs.setting;
              source = originPrefs.source;
              return true;
            }
            return false;
          });

      if (!isSet) {
        this.prefs_.chooserExceptions[contentType].some(
            (chooserException: RawChooserException) => {
              return chooserException.sites.some(originPrefs => {
                if (originPrefs.origin === origin) {
                  setting = originPrefs.setting;
                  source = originPrefs.source;
                  return true;
                }
                return false;
              });
            });
      }

      assert(
          setting !== undefined,
          'There was no exception set for origin: ' + origin +
              ' and contentType: ' + contentType);

      exceptionList.push({
        embeddingOrigin: '',
        incognito: false,
        origin: origin,
        displayName: '',
        setting: setting!,
        source: source!,
        isEmbargoed: false,
        type: '',
      });
    });
    return Promise.resolve(exceptionList);
  }

  /** @override */
  setCategoryPermissionForPattern(
      primaryPattern: string, secondaryPattern: string, contentType: string,
      value: string, incognito: boolean) {
    this.methodCalled(
        'setCategoryPermissionForPattern',
        [primaryPattern, secondaryPattern, contentType, value, incognito]);
  }

  /** @override */
  fetchZoomLevels() {
    webUIListenerCallback('onZoomLevelsChanged', this.zoomList_);
    this.methodCalled('fetchZoomLevels');
  }

  /** @override */
  removeZoomLevel(host: string) {
    this.methodCalled('removeZoomLevel', [host]);
  }

  /** @override */
  observeProtocolHandlers() {
    webUIListenerCallback('setHandlersEnabled', true);
    webUIListenerCallback('setProtocolHandlers', this.protocolHandlers_);
    webUIListenerCallback('setIgnoredProtocolHandlers', this.ignoredProtocols_);
    this.methodCalled('observeProtocolHandlers');
  }

  /** @override */
  observeAppProtocolHandlers() {
    webUIListenerCallback(
        'setAppAllowedProtocolHandlers', this.appAllowedProtocolHandlers_);
    webUIListenerCallback(
        'setAppDisallowedProtocolHandlers',
        this.appDisallowedProtocolHandlers_);
    this.methodCalled('observeAppProtocolHandlers');
  }

  /** @override */
  observeProtocolHandlersEnabledState() {
    webUIListenerCallback('setHandlersEnabled', true);
    this.methodCalled('observeProtocolHandlersEnabledState');
  }

  /** @override */
  setProtocolDefault() {
    this.methodCalled('setProtocolDefault', arguments);
  }

  /** @override */
  removeProtocolHandler() {
    this.methodCalled('removeProtocolHandler', arguments);
  }

  /** @override */
  removeAppAllowedHandler() {
    this.methodCalled('removeAppAllowedHandler', arguments);
  }

  /** @override */
  removeAppDisallowedHandler() {
    this.methodCalled('removeAppDisallowedHandler', arguments);
  }

  /** @override */
  updateIncognitoStatus() {
    this.methodCalled('updateIncognitoStatus', arguments);
  }

  /** @override */
  fetchBlockAutoplayStatus() {
    this.methodCalled('fetchBlockAutoplayStatus');
  }

  /** @override */
  clearSiteGroupDataAndCookies() {
    this.methodCalled('clearSiteGroupDataAndCookies');
  }

  /** @override */
  clearUnpartitionedOriginDataAndCookies(origin: string) {
    this.methodCalled('clearUnpartitionedOriginDataAndCookies', origin);
  }

  /** @override */
  clearPartitionedOriginDataAndCookies(origin: string, groupingKey: string) {
    this.methodCalled(
        'clearPartitionedOriginDataAndCookies', [origin, groupingKey]);
  }

  /** @override */
  recordAction() {
    this.methodCalled('recordAction');
  }

  setRecentSitePermissions(permissions: RecentSitePermissions[]) {
    this.recentSitePermissions_ = permissions;
  }

  /** @override */
  getRecentSitePermissions() {
    this.methodCalled('getRecentSitePermissions');
    return Promise.resolve(this.recentSitePermissions_);
  }

  /** @override */
  initializeCaptureDevices() {}

  /** @override */
  setPreferredCaptureDevice() {}

  /** @override */
  setProtocolHandlerDefault(value: boolean) {
    this.methodCalled('setProtocolHandlerDefault', value);
  }

  getRwsMembershipLabel(rwsNumMembers: number, rwsOwner: string) {
    this.methodCalled('getRwsMembershipLabel', rwsNumMembers, rwsOwner);
    return Promise.resolve([
      `${rwsNumMembers}`,
      (rwsNumMembers === 1 ? 'site' : 'sites'),
      `in ${rwsOwner}'s group`,
    ].join(' '));
  }

  getNumCookiesString(numCookies: number) {
    this.methodCalled('getNumCookiesString', numCookies);
    return Promise.resolve(
        `${numCookies} ` + (numCookies === 1 ? 'cookie' : 'cookies'));
  }

  getSystemDeniedPermissions() {
    this.methodCalled('getSystemDeniedPermissions');
    return Promise.resolve([]);
  }

  openSystemPermissionSettings(contentType: string): void {
    this.methodCalled('openSystemPermissionSettings', contentType);
  }

  getExtensionName(id: string) {
    this.methodCalled('getExtensionName', id);
    return Promise.resolve(`Test Extension ${id}`);
  }

  setFileSystemGrants(fileSystemGrantsForOriginList: OriginFileSystemGrants[]):
      void {
    this.fileSystemGrantsList_ = fileSystemGrantsForOriginList;
  }

  getFileSystemGrants(): Promise<OriginFileSystemGrants[]> {
    this.methodCalled('getFileSystemGrants');
    return Promise.resolve(this.fileSystemGrantsList_);
  }

  setStorageAccessExceptionList(storageAccessExceptionList:
                                    StorageAccessSiteException[]) {
    this.storageAccessExceptionList_ = storageAccessExceptionList;
  }

  /** @override */
  getStorageAccessExceptionList(categorySubtype: ContentSetting):
      Promise<StorageAccessSiteException[]> {
    this.methodCalled('getStorageAccessExceptionList', categorySubtype);

    return Promise.resolve(this.storageAccessExceptionList_.filter(
        site => site.setting === categorySubtype));
  }

  revokeFileSystemGrant(origin: string, filePath: string): void {
    this.methodCalled('revokeFileSystemGrant', [origin, filePath]);
  }

  revokeFileSystemGrants(origin: string): void {
    this.methodCalled('revokeFileSystemGrants', origin);
  }
}