chromium/chrome/test/data/webui/settings/security_keys_set_pin_dialog_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.

import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {PromiseResolver} from 'chrome://resources/js/promise_resolver.js';
import type {CrInputElement, SecurityKeysPinBrowserProxy, SettingsSecurityKeysSetPinDialogElement} from 'chrome://settings/lazy_load.js';
import {SecurityKeysPinBrowserProxyImpl, SetPinDialogPage} from 'chrome://settings/lazy_load.js';
import {assertEquals, assertFalse, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {eventToPromise} from 'chrome://webui-test/test_util.js';

import {assertShown} from './security_keys_test_util.js';
import {TestSecurityKeysBrowserProxy} from './test_security_keys_browser_proxy.js';

const currentMinPinLength = 6;
const newMinPinLength = 8;

class TestSecurityKeysPinBrowserProxy extends TestSecurityKeysBrowserProxy
    implements SecurityKeysPinBrowserProxy {
  constructor() {
    super([
      'startSetPin',
      'setPin',
      'close',
    ]);
  }

  startSetPin() {
    return this.handleMethod('startSetPin');
  }

  setPin(oldPIN: string, newPIN: string) {
    return this.handleMethod('setPin', {oldPIN, newPIN});
  }

  close() {
    this.methodCalled('close');
  }
}

suite('SecurityKeysSetPINDialog', function() {
  const tooShortCurrentPIN = 'abcd';
  const validCurrentPIN = 'abcdef';
  const tooShortNewPIN = '123456';
  const validNewPIN = '12345678';
  const anotherValidNewPIN = '87654321';

  let dialog: SettingsSecurityKeysSetPinDialogElement;
  let allDivs: string[];
  let browserProxy: TestSecurityKeysPinBrowserProxy;

  setup(function() {
    browserProxy = new TestSecurityKeysPinBrowserProxy();
    SecurityKeysPinBrowserProxyImpl.setInstance(browserProxy);
    document.body.innerHTML = window.trustedTypes!.emptyHTML;
    dialog = document.createElement('settings-security-keys-set-pin-dialog');
    allDivs = Object.values(SetPinDialogPage);
  });

  function assertComplete() {
    assertEquals(dialog.$.closeButton.textContent!.trim(), 'OK');
    assertEquals(dialog.$.closeButton.className, 'action-button');
    assertEquals(dialog.$.pinSubmit.hidden, true);
  }

  function assertNotComplete() {
    assertEquals(dialog.$.closeButton.textContent!.trim(), 'Cancel');
    assertEquals(dialog.$.closeButton.className, 'cancel-button');
    assertEquals(dialog.$.pinSubmit.hidden, false);
  }

  test('Initialization', async function() {
    document.body.appendChild(dialog);
    await browserProxy.whenCalled('startSetPin');
    assertShown(allDivs, dialog, 'initial');
    assertNotComplete();
  });

  // Test error codes that are returned immediately.
  for (const testCase of [
           [1 /* INVALID_COMMAND */, 'noPINSupport'],
           [52 /* temporary lock */, 'reinsert'], [50 /* locked */, 'locked'],
           [1000 /* invalid error */, 'error']]) {
    test('ImmediateError' + testCase[0]!.toString(), async function() {
      browserProxy.setResponseFor(
          'startSetPin', Promise.resolve({done: true, error: testCase[0]}));
      document.body.appendChild(dialog);

      await browserProxy.whenCalled('startSetPin');
      await browserProxy.whenCalled('close');
      assertComplete();
      assertShown(allDivs, dialog, (testCase[1] as string));
      if (testCase[1] === 'error') {
        // Unhandled error codes display the numeric code.
        assertTrue(dialog.$.error.textContent!.trim().includes(
            testCase[0]!.toString()));
      }
    });
  }

  test('ZeroRetries', async function() {
    // Authenticators can also signal that they are locked by indicating zero
    // attempts remaining.
    browserProxy.setResponseFor('startSetPin', Promise.resolve({
      done: false,
      error: null,
      currentMinPinLength,
      newMinPinLength,
      retries: 0,
    }));
    document.body.appendChild(dialog);

    await browserProxy.whenCalled('startSetPin');
    await browserProxy.whenCalled('close');
    assertComplete();
    assertShown(allDivs, dialog, 'locked');
  });

  async function setPINEntry(
      inputElement: CrInputElement, pinValue: string): Promise<void> {
    inputElement.value = pinValue;
    await inputElement.updateComplete;
    // Dispatch input events to trigger validation and UI updates.
    inputElement.dispatchEvent(
        new CustomEvent('input', {bubbles: true, cancelable: true}));
  }

  async function setNewPINEntries(
      pinValue: string, confirmPINValue: string): Promise<void> {
    await setPINEntry(dialog.$.newPIN, pinValue);
    await setPINEntry(dialog.$.confirmPIN, confirmPINValue);
    const ret = eventToPromise('ui-ready', dialog);
    dialog.$.pinSubmit.click();
    return ret;
  }

  async function setChangePINEntries(
      currentPINValue: string, pinValue: string,
      confirmPINValue: string): Promise<void> {
    await setPINEntry(dialog.$.newPIN, pinValue);
    await setPINEntry(dialog.$.confirmPIN, confirmPINValue);
    await setPINEntry(dialog.$.currentPIN, currentPINValue);
    dialog.$.pinSubmit.click();
  }

  test('SetPIN', async function() {
    const startSetPINResolver = new PromiseResolver();
    browserProxy.setResponseFor('startSetPin', startSetPINResolver.promise);
    document.body.appendChild(dialog);
    const uiReady = eventToPromise('ui-ready', dialog);

    await browserProxy.whenCalled('startSetPin');
    startSetPINResolver.resolve({
      done: false,
      error: null,
      currentMinPinLength,
      newMinPinLength,
      retries: null,
    });
    await uiReady;
    assertNotComplete();
    assertShown(allDivs, dialog, 'pinPrompt');
    assertTrue(dialog.$.currentPINEntry.hidden);

    await setNewPINEntries(tooShortNewPIN, '');
    assertTrue(dialog.$.newPIN.invalid);
    assertFalse(dialog.$.confirmPIN.invalid);

    await setNewPINEntries(tooShortNewPIN, tooShortNewPIN);
    assertTrue(dialog.$.newPIN.invalid);
    assertFalse(dialog.$.confirmPIN.invalid);

    await setNewPINEntries(validNewPIN, anotherValidNewPIN);
    assertFalse(dialog.$.newPIN.invalid);
    assertTrue(dialog.$.confirmPIN.invalid);

    const setPINResolver = new PromiseResolver();
    browserProxy.setResponseFor('setPin', setPINResolver.promise);
    setNewPINEntries(validNewPIN, validNewPIN);
    const {oldPIN, newPIN} = await browserProxy.whenCalled('setPin');
    assertTrue(dialog.$.pinSubmit.disabled);
    assertEquals(oldPIN, '');
    assertEquals(newPIN, validNewPIN);

    setPINResolver.resolve({done: true, error: 0});
    await browserProxy.whenCalled('close');
    assertShown(allDivs, dialog, 'success');
    assertComplete();
  });

  // Test error codes that are only returned after attempting to set a PIN.
  for (const testCase of [
           [52 /* temporary lock */, 'reinsert'], [50 /* locked */, 'locked'],
           [1000 /* invalid error */, 'error']]) {
    test('Error' + testCase[0]!.toString(), async function() {
      const startSetPINResolver = new PromiseResolver();
      browserProxy.setResponseFor('startSetPin', startSetPINResolver.promise);
      document.body.appendChild(dialog);
      const uiReady = eventToPromise('ui-ready', dialog);

      await browserProxy.whenCalled('startSetPin');
      startSetPINResolver.resolve({
        done: false,
        error: null,
        currentMinPinLength,
        newMinPinLength,
        retries: null,
      });
      await uiReady;

      browserProxy.setResponseFor(
          'setPin', Promise.resolve({done: true, error: testCase[0]}));
      setNewPINEntries(validNewPIN, validNewPIN);
      await browserProxy.whenCalled('setPin');
      await browserProxy.whenCalled('close');
      assertComplete();
      assertShown(allDivs, dialog, (testCase[1] as string));
      if (testCase[1] === 'error') {
        // Unhandled error codes display the numeric code.
        assertTrue(dialog.$.error.textContent!.trim().includes(
            testCase[0]!.toString()));
      }
    });
  }

  test('ChangePIN', async function() {
    const startSetPINResolver = new PromiseResolver();
    browserProxy.setResponseFor('startSetPin', startSetPINResolver.promise);
    document.body.appendChild(dialog);
    let uiReady = eventToPromise('ui-ready', dialog);

    await browserProxy.whenCalled('startSetPin');
    startSetPINResolver.resolve({
      done: false,
      error: null,
      currentMinPinLength,
      newMinPinLength,
      retries: 2,
    });
    await uiReady;
    assertNotComplete();
    assertShown(allDivs, dialog, 'pinPrompt');
    assertFalse(dialog.$.currentPINEntry.hidden);

    await setChangePINEntries(tooShortCurrentPIN, '', '');
    assertTrue(dialog.$.currentPIN.invalid);
    assertFalse(dialog.$.newPIN.invalid);
    assertFalse(dialog.$.confirmPIN.invalid);

    await setChangePINEntries(tooShortCurrentPIN, tooShortNewPIN, '');
    assertTrue(dialog.$.currentPIN.invalid);
    assertFalse(dialog.$.newPIN.invalid);
    assertFalse(dialog.$.confirmPIN.invalid);

    await setChangePINEntries(validCurrentPIN, tooShortNewPIN, validNewPIN);
    assertFalse(dialog.$.currentPIN.invalid);
    assertTrue(dialog.$.newPIN.invalid);
    assertFalse(dialog.$.confirmPIN.invalid);

    await setChangePINEntries(tooShortCurrentPIN, validNewPIN, validNewPIN);
    assertTrue(dialog.$.currentPIN.invalid);
    assertFalse(dialog.$.newPIN.invalid);
    assertFalse(dialog.$.confirmPIN.invalid);

    await setChangePINEntries(validNewPIN, validNewPIN, validNewPIN);
    assertFalse(dialog.$.currentPIN.invalid);
    assertTrue(dialog.$.newPIN.invalid);
    assertEquals(
        dialog.$.newPIN.errorMessage,
        loadTimeData.getString('securityKeysSamePINAsCurrent'));
    assertFalse(dialog.$.confirmPIN.invalid);

    let setPINResolver = new PromiseResolver();
    browserProxy.setResponseFor('setPin', setPINResolver.promise);
    await setPINEntry(dialog.$.currentPIN, validCurrentPIN);
    await setPINEntry(dialog.$.newPIN, validNewPIN);
    await setPINEntry(dialog.$.confirmPIN, validNewPIN);
    dialog.$.pinSubmit.click();
    let {oldPIN, newPIN} = await browserProxy.whenCalled('setPin');
    assertShown(allDivs, dialog, 'pinPrompt');
    assertNotComplete();
    assertTrue(dialog.$.pinSubmit.disabled);
    assertEquals(oldPIN, validCurrentPIN);
    assertEquals(newPIN, validNewPIN);

    // Simulate an incorrect PIN.
    uiReady = eventToPromise('ui-ready', dialog);
    setPINResolver.resolve({done: true, error: 49});
    await uiReady;
    assertTrue(dialog.$.currentPIN.invalid);
    // Text box for current PIN should not be cleared.
    assertEquals(dialog.$.currentPIN.value, validCurrentPIN);

    await setPINEntry(dialog.$.currentPIN, anotherValidNewPIN);

    browserProxy.resetResolver('setPin');
    setPINResolver = new PromiseResolver();
    browserProxy.setResponseFor('setPin', setPINResolver.promise);
    dialog.$.pinSubmit.click();
    ({oldPIN, newPIN} = await browserProxy.whenCalled('setPin'));
    assertTrue(dialog.$.pinSubmit.disabled);
    assertEquals(oldPIN, anotherValidNewPIN);
    assertEquals(newPIN, validNewPIN);

    setPINResolver.resolve({done: true, error: 0});
    await browserProxy.whenCalled('close');
    assertShown(allDivs, dialog, 'success');
    assertComplete();
  });
});