chromium/chrome/test/data/webui/chromeos/settings/crostini_page/crostini_port_forwarding_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 {ContainerInfo, ContainerSelectElement, CrostiniBrowserProxyImpl, CrostiniPortForwardingElement, CrostiniPortSetting} from 'chrome://os-settings/lazy_load.js';
import {CrInputElement, CrToastElement, CrToggleElement, Router, routes} from 'chrome://os-settings/os_settings.js';
import {webUIListenerCallback} from 'chrome://resources/js/cr.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {flush} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {assertEquals, assertFalse, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {flushTasks} from 'chrome://webui-test/polymer_test_util.js';
import {isVisible} from 'chrome://webui-test/test_util.js';

import {TestCrostiniBrowserProxy} from './test_crostini_browser_proxy.js';

let subpage: CrostiniPortForwardingElement;
let crostiniBrowserProxy: TestCrostiniBrowserProxy;

interface PrefParams {
  sharedPaths?: {[key: string]: string[]};
  forwardedPorts?: CrostiniPortSetting[];
  micAllowed?: boolean;
  arcEnabled?: boolean;
  bruschettaInstalled?: boolean;
}

function setCrostiniPrefs(enabled: boolean, {
  sharedPaths = {},
  forwardedPorts = [],
  micAllowed = false,
  arcEnabled = false,
  bruschettaInstalled = false,
}: PrefParams = {}): void {
  subpage.prefs = {
    arc: {
      enabled: {value: arcEnabled},
    },
    bruschetta: {
      installed: {
        value: bruschettaInstalled,
      },
    },
    crostini: {
      enabled: {value: enabled},
      mic_allowed: {value: micAllowed},
      port_forwarding: {ports: {value: forwardedPorts}},
    },
    guest_os: {
      paths_shared_to_vms: {value: sharedPaths},
    },
  };
  flush();
}

function selectContainerByIndex(
    select: ContainerSelectElement, index: number): void {
  const mdSelect = select.shadowRoot!.querySelector<HTMLSelectElement>(
      'select#selectContainer.md-select');
  assertTrue(!!mdSelect);
  mdSelect.selectedIndex = index;
  mdSelect.dispatchEvent(new CustomEvent('change'));
  flush();
}

const allContainers: ContainerInfo[] = [
  {
    id: {
      vm_name: 'termina',
      container_name: 'penguin',
    },
    ipv4: '1.2.3.4',
  },
  {
    id: {
      vm_name: 'not-termina',
      container_name: 'not-penguin',

    },
    ipv4: '1.2.3.5',
  },
];

suite('<settings-crostini-port-forwarding>', () => {
  setup(async () => {
    loadTimeData.overrideValues({
      isCrostiniAllowed: true,
      isCrostiniSupported: true,
    });

    crostiniBrowserProxy = new TestCrostiniBrowserProxy();
    crostiniBrowserProxy.portOperationSuccess = true;
    crostiniBrowserProxy.containerInfo = allContainers;
    CrostiniBrowserProxyImpl.setInstanceForTesting(crostiniBrowserProxy);

    Router.getInstance().navigateTo(routes.CROSTINI_PORT_FORWARDING);
    subpage = document.createElement('settings-crostini-port-forwarding');
    document.body.appendChild(subpage);
    setCrostiniPrefs(true, {
      forwardedPorts: [
        {
          port_number: 5000,
          protocol_type: 0,
          label: 'Label1',
          vm_name: 'termina',
          container_name: 'penguin',
          container_id: {
            vm_name: 'termina',
            container_name: 'penguin',
          },
          is_active: false,
        },
        {
          port_number: 5001,
          protocol_type: 1,
          label: 'Label2',
          vm_name: 'not-termina',
          container_name: 'not-penguin',
          container_id: {
            vm_name: 'not-termina',
            container_name: 'not-penguin',
          },
          is_active: false,
        },
      ],
    });
    await flushTasks();

    assertEquals(1, crostiniBrowserProxy.getCallCount('requestContainerInfo'));
  });

  teardown(() => {
    subpage.remove();
    Router.getInstance().resetRouteForTesting();
    crostiniBrowserProxy.reset();
  });

  test('Display ports', () => {
    // Extra list item for the titles.
    assertEquals(4, subpage.shadowRoot!.querySelectorAll('.list-item').length);
  });

  test('Add port success', async () => {
    const addPortBtn = subpage.shadowRoot!.querySelector<HTMLButtonElement>(
        '#addPort cr-button');
    assertTrue(!!addPortBtn);
    addPortBtn.click();

    await flushTasks();
    const addPortDialogElement =
        subpage.shadowRoot!.querySelector('settings-crostini-add-port-dialog');
    assertTrue(!!addPortDialogElement);
    const portNumberInput =
        addPortDialogElement.shadowRoot!.querySelector<CrInputElement>(
            '#portNumberInput');
    assertTrue(!!portNumberInput);
    portNumberInput.focus();
    portNumberInput.value = '5002';
    portNumberInput.blur();
    assertFalse(portNumberInput.invalid);
    const portLabelInput =
        addPortDialogElement.shadowRoot!.querySelector<CrInputElement>(
            '#portLabelInput');
    assertTrue(!!portLabelInput);
    portLabelInput.value = 'Some Label';
    const select = addPortDialogElement.shadowRoot!.querySelector(
        'settings-guest-os-container-select');
    assertTrue(!!select);
    selectContainerByIndex(select, 1);

    const continueButton =
        addPortDialogElement.shadowRoot!.querySelector<HTMLButtonElement>(
            'cr-dialog cr-button[id="continue"]');
    assertTrue(!!continueButton);
    continueButton.click();
    assertEquals(
        1, crostiniBrowserProxy.getCallCount('addCrostiniPortForward'));
    const args = crostiniBrowserProxy.getArgs('addCrostiniPortForward')[0];
    assertEquals(4, args.length);
    assertEquals('not-termina', args[0].vm_name);
    assertEquals('not-penguin', args[0].container_name);
  });

  test('Add port fail', async () => {
    const addPortBtn = subpage.shadowRoot!.querySelector<HTMLButtonElement>(
        '#addPort cr-button');
    assertTrue(!!addPortBtn);
    addPortBtn.click();

    await flushTasks();
    const addPortDialogElement =
        subpage.shadowRoot!.querySelector('settings-crostini-add-port-dialog');
    assertTrue(!!addPortDialogElement);
    const portNumberInput =
        addPortDialogElement.shadowRoot!.querySelector<CrInputElement>(
            '#portNumberInput');
    assertTrue(!!portNumberInput);
    const continueButton =
        addPortDialogElement.shadowRoot!.querySelector<HTMLButtonElement>(
            'cr-dialog cr-button[id="continue"]');
    assertTrue(!!continueButton);

    assertFalse(portNumberInput.invalid);
    portNumberInput.focus();
    portNumberInput.value = '1023';
    continueButton.click();
    assertEquals(
        0, crostiniBrowserProxy.getCallCount('addCrostiniPortForward'));
    assertTrue(continueButton.disabled);
    assertTrue(portNumberInput.invalid);
    assertEquals(
        loadTimeData.getString('crostiniPortForwardingAddError'),
        portNumberInput.errorMessage);

    portNumberInput.value = '65536';
    assertTrue(continueButton.disabled);
    assertTrue(portNumberInput.invalid);
    assertEquals(
        loadTimeData.getString('crostiniPortForwardingAddError'),
        portNumberInput.errorMessage);

    portNumberInput.focus();
    portNumberInput.value = '5000';
    portNumberInput.blur();

    continueButton.click();
    assertTrue(continueButton.disabled);
    assertTrue(portNumberInput.invalid);
    assertEquals(
        loadTimeData.getString('crostiniPortForwardingAddExisting'),
        portNumberInput.errorMessage);

    portNumberInput.focus();
    portNumberInput.value = '1024';
    portNumberInput.blur();
    assertFalse(continueButton.disabled);
    assertFalse(portNumberInput.invalid);
    assertEquals('', portNumberInput.errorMessage);
  });

  test('Add port cancel', async () => {
    const addPortBtn = subpage.shadowRoot!.querySelector<HTMLButtonElement>(
        '#addPort cr-button');
    assertTrue(!!addPortBtn);
    addPortBtn.click();

    await flushTasks();
    const addPortDialogElement =
        subpage.shadowRoot!.querySelector('settings-crostini-add-port-dialog');
    assertTrue(!!addPortDialogElement);
    const cancelBtn =
        addPortDialogElement.shadowRoot!.querySelector<HTMLButtonElement>(
            'cr-dialog cr-button[id="cancel"]');
    assertTrue(!!cancelBtn);
    cancelBtn.click();
    flush();

    assertFalse(isVisible(addPortDialogElement));
  });

  test('Remove all ports', async () => {
    const showMenuBtn = subpage.shadowRoot!.querySelector<HTMLButtonElement>(
        '#showRemoveAllPortsMenu');
    assertTrue(!!showMenuBtn);
    showMenuBtn.click();

    await flushTasks();
    const removeBtn = subpage.shadowRoot!.querySelector<HTMLButtonElement>(
        '#removeAllPortsButton');
    assertTrue(!!removeBtn);
    removeBtn.click();
    assertEquals(
        2, crostiniBrowserProxy.getCallCount('removeAllCrostiniPortForwards'));
  });

  test('Remove single port', () => {
    const button = subpage.shadowRoot!.querySelector<HTMLButtonElement>(
        '#removeSinglePortButton0-0');
    assertTrue(!!button);
    button.click();
    assertEquals(
        1, crostiniBrowserProxy.getCallCount('removeCrostiniPortForward'));
    const args = crostiniBrowserProxy.getArgs('removeCrostiniPortForward')[0];
    assertEquals(3, args.length);
    assertEquals('termina', args[0].vm_name);
    assertEquals('penguin', args[0].container_name);
  });

  test('Activate single port success', async () => {
    let errorToast =
        subpage.shadowRoot!.querySelector<CrToastElement>('#errorToast');
    assertTrue(!!errorToast);
    assertFalse(errorToast.open);
    await flushTasks();

    const crToggle = subpage.shadowRoot!.querySelector<CrToggleElement>(
        '#toggleActivationButton0-0');
    assertTrue(!!crToggle);
    assertFalse(crToggle.disabled);
    crToggle.click();

    await flushTasks();
    assertEquals(
        1, crostiniBrowserProxy.getCallCount('activateCrostiniPortForward'));
    errorToast =
        subpage.shadowRoot!.querySelector<CrToastElement>('#errorToast');
    assertTrue(!!errorToast);
    assertFalse(errorToast.open);
  });

  test('Activate single port fail', async () => {
    crostiniBrowserProxy.portOperationSuccess = false;
    let errorToast =
        subpage.shadowRoot!.querySelector<CrToastElement>('#errorToast');
    assertTrue(!!errorToast);
    assertFalse(errorToast.open);

    const crToggle = subpage.shadowRoot!.querySelector<CrToggleElement>(
        '#toggleActivationButton1-0');
    assertTrue(!!crToggle);
    assertFalse(crToggle.disabled);
    assertFalse(crToggle.checked);
    crToggle.click();

    await flushTasks();
    assertEquals(
        1, crostiniBrowserProxy.getCallCount('activateCrostiniPortForward'));
    assertFalse(crToggle.checked);
    errorToast =
        subpage.shadowRoot!.querySelector<CrToastElement>('#errorToast');
    assertTrue(!!errorToast);
    assertTrue(errorToast.open);
  });

  test('Deactivate single port', async () => {
    const crToggle = subpage.shadowRoot!.querySelector<CrToggleElement>(
        '#toggleActivationButton0-0');
    assertTrue(!!crToggle);
    assertFalse(crToggle.disabled);
    crToggle.checked = true;
    crToggle.click();

    await flushTasks();
    assertEquals(
        1, crostiniBrowserProxy.getCallCount('deactivateCrostiniPortForward'));
  });

  test('Active ports changed', async () => {
    setCrostiniPrefs(true, {
      forwardedPorts: [
        {
          port_number: 5000,
          protocol_type: 0,
          label: 'Label1',
          vm_name: 'termina',
          container_name: 'penguin',
          container_id: {
            vm_name: 'termina',
            container_name: 'penguin',
          },
          is_active: false,
        },
      ],
    });
    const crToggle = subpage.shadowRoot!.querySelector<CrToggleElement>(
        '#toggleActivationButton0-0');
    assertTrue(!!crToggle);

    webUIListenerCallback(
        'crostini-port-forwarder-active-ports-changed',
        [{'port_number': 5000, 'protocol_type': 0}]);
    await flushTasks();
    assertTrue(crToggle.checked);

    webUIListenerCallback('crostini-port-forwarder-active-ports-changed', []);
    await flushTasks();
    assertFalse(crToggle.checked);
  });

  test('Port prefs change', () => {
    // Default prefs should have list items per port, plus one per
    // container.
    assertEquals(4, subpage.shadowRoot!.querySelectorAll('.list-item').length);

    // When only one of the default container has ports, we lose an item for
    // the extra container heading.
    setCrostiniPrefs(true, {
      forwardedPorts: [
        {
          port_number: 5000,
          protocol_type: 0,
          label: 'Label1',
          vm_name: 'termina',
          container_name: 'penguin',
          container_id: {
            vm_name: 'termina',
            container_name: 'penguin',
          },
          is_active: false,
        },
        {
          port_number: 5001,
          protocol_type: 0,
          label: 'Label2',
          vm_name: 'termina',
          container_name: 'penguin',
          container_id: {
            vm_name: 'termina',
            container_name: 'penguin',
          },
          is_active: false,
        },
      ],
    });
    assertEquals(3, subpage.shadowRoot!.querySelectorAll('.list-item').length);
    setCrostiniPrefs(true, {
      forwardedPorts: [
        {
          port_number: 5000,
          protocol_type: 0,
          label: 'Label1',
          vm_name: 'termina',
          container_name: 'penguin',
          container_id: {
            vm_name: 'termina',
            container_name: 'penguin',
          },
          is_active: false,
        },
        {
          port_number: 5001,
          protocol_type: 0,
          label: 'Label2',
          vm_name: 'termina',
          container_name: 'penguin',
          container_id: {
            vm_name: 'termina',
            container_name: 'penguin',
          },
          is_active: false,
        },
        {
          port_number: 5002,
          protocol_type: 0,
          label: 'Label3',
          vm_name: 'termina',
          container_name: 'penguin',
          container_id: {
            vm_name: 'termina',
            container_name: 'penguin',
          },
          is_active: false,
        },
      ],
    });
    assertEquals(4, subpage.shadowRoot!.querySelectorAll('.list-item').length);
    setCrostiniPrefs(true, {forwardedPorts: []});
    assertEquals(0, subpage.shadowRoot!.querySelectorAll('.list-item').length);
  });

  test('Container stop and start', async () => {
    const crToggle = subpage.shadowRoot!.querySelector<CrToggleElement>(
        '#toggleActivationButton0-0');
    assertTrue(!!crToggle);
    assertFalse(crToggle.disabled);

    allContainers[0]!.ipv4 = null;
    webUIListenerCallback(
        'crostini-container-info', structuredClone(allContainers));
    await flushTasks();
    assertTrue(crToggle.disabled);

    allContainers[0]!.ipv4 = '1.2.3.4';
    webUIListenerCallback(
        'crostini-container-info', structuredClone(allContainers));
    await flushTasks();
    assertFalse(crToggle.disabled);
  });
});