chromium/chrome/test/data/webui/settings/security_keys_bio_enrollment_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 {webUIListenerCallback} from 'chrome://resources/js/cr.js';
import {PromiseResolver} from 'chrome://resources/js/promise_resolver.js';
import {flush} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import type {SecurityKeysBioEnrollProxy, SettingsSecurityKeysBioEnrollDialogElement} from 'chrome://settings/lazy_load.js';
import {BioEnrollDialogPage, Ctap2Status, SampleStatus, SecurityKeysBioEnrollProxyImpl} from 'chrome://settings/lazy_load.js';
import {assertDeepEquals, assertEquals, assertFalse, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {eventToPromise, microtasksFinished} 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;

class TestSecurityKeysBioEnrollProxy extends TestSecurityKeysBrowserProxy
    implements SecurityKeysBioEnrollProxy {
  constructor() {
    super([
      'startBioEnroll',
      'providePin',
      'getSensorInfo',
      'enumerateEnrollments',
      'startEnrolling',
      'cancelEnrollment',
      'deleteEnrollment',
      'renameEnrollment',
      'close',
    ]);
  }

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

  providePin(pin: string) {
    return this.handleMethod('providePin', pin);
  }

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

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

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

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

  deleteEnrollment(id: string) {
    return this.handleMethod('deleteEnrollment', id);
  }

  renameEnrollment(id: string, name: string) {
    return this.handleMethod('renameEnrollment', [id, name]);
  }

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

suite('SecurityKeysBioEnrollment', function() {
  let dialog: SettingsSecurityKeysBioEnrollDialogElement;
  let allDivs: string[];
  let browserProxy: TestSecurityKeysBioEnrollProxy;

  setup(function() {
    browserProxy = new TestSecurityKeysBioEnrollProxy();
    SecurityKeysBioEnrollProxyImpl.setInstance(browserProxy);
    document.body.innerHTML = window.trustedTypes!.emptyHTML;
    dialog = document.createElement('settings-security-keys-bio-enroll-dialog');
    allDivs = Object.values(BioEnrollDialogPage);
  });

  test('Initialization', async function() {
    document.body.appendChild(dialog);
    await browserProxy.whenCalled('startBioEnroll');
    assertShown(allDivs, dialog, 'initial');
    assertFalse(dialog.$.cancelButton.hidden);
  });

  test('Cancel', async function() {
    document.body.appendChild(dialog);
    await browserProxy.whenCalled('startBioEnroll');
    assertShown(allDivs, dialog, 'initial');
    dialog.$.cancelButton.click();
    await browserProxy.whenCalled('close');
    assertFalse(dialog.$.dialog.open);
  });

  test('Finished', async function() {
    const resolver = new PromiseResolver();
    browserProxy.setResponseFor('startBioEnroll', resolver.promise);

    document.body.appendChild(dialog);
    await browserProxy.whenCalled('startBioEnroll');
    assertShown(allDivs, dialog, 'initial');
    resolver.resolve([currentMinPinLength]);
    await microtasksFinished();
    assertShown(allDivs, dialog, 'pinPrompt');

    const error = 'foo bar baz';
    webUIListenerCallback('security-keys-bio-enroll-error', error);
    await microtasksFinished();
    assertShown(allDivs, dialog, 'error');
    assertTrue(dialog.$.confirmButton.hidden);
    assertTrue(dialog.$.error.textContent!.trim().includes(error));
  });

  test('PINChangeError', async function() {
    const resolver = new PromiseResolver();
    browserProxy.setResponseFor('startBioEnroll', resolver.promise);

    document.body.appendChild(dialog);
    await browserProxy.whenCalled('startBioEnroll');
    assertShown(allDivs, dialog, 'initial');
    resolver.resolve([currentMinPinLength]);
    await microtasksFinished();
    assertShown(allDivs, dialog, 'pinPrompt');

    const error = 'something about setting a new PIN';
    webUIListenerCallback(
        'security-keys-bio-enroll-error', error, true /* requiresPINChange */);
    await microtasksFinished();
    assertShown(allDivs, dialog, 'error');
    assertFalse(dialog.$.confirmButton.hidden);
    assertFalse(dialog.$.confirmButton.disabled);
    assertTrue(dialog.$.error.textContent!.trim().includes(error));

    const setPinEvent = eventToPromise('bio-enroll-set-pin', dialog);
    dialog.$.confirmButton.click();
    await setPinEvent;
  });

  test('Enrollments', async function() {
    const startResolver = new PromiseResolver();
    browserProxy.setResponseFor('startBioEnroll', startResolver.promise);
    const pinResolver = new PromiseResolver();
    browserProxy.setResponseFor('providePin', pinResolver.promise);
    const getSensorInfoResolver = new PromiseResolver();
    browserProxy.setResponseFor('getSensorInfo', getSensorInfoResolver.promise);
    const enumerateResolver = new PromiseResolver();
    browserProxy.setResponseFor(
        'enumerateEnrollments', enumerateResolver.promise);
    const deleteResolver = new PromiseResolver();
    browserProxy.setResponseFor('deleteEnrollment', deleteResolver.promise);

    document.body.appendChild(dialog);
    await browserProxy.whenCalled('startBioEnroll');
    assertShown(allDivs, dialog, 'initial');

    // Simulate PIN entry.
    let uiReady = eventToPromise('bio-enroll-dialog-ready-for-testing', dialog);
    startResolver.resolve([currentMinPinLength]);
    await uiReady;
    assertShown(allDivs, dialog, 'pinPrompt');
    assertEquals(currentMinPinLength, dialog.$.pin.minPinLength);
    dialog.$.pin.$.pin.value = '000000';
    await dialog.$.pin.$.pin.updateComplete;
    dialog.$.confirmButton.click();
    const pin = await browserProxy.whenCalled('providePin');
    assertEquals(pin, '000000');
    pinResolver.resolve(null);

    await browserProxy.whenCalled('getSensorInfo');
    getSensorInfoResolver.resolve({
      maxTemplateFriendlyName: 10,
    });

    // Show a list of three enrollments.
    await browserProxy.whenCalled('enumerateEnrollments');
    uiReady = eventToPromise('bio-enroll-dialog-ready-for-testing', dialog);

    const fingerprintA = {
      name: 'FingerprintA',
      id: '1234',
    };
    const fingerprintB = {
      name: 'FingerprintB',
      id: '4321',
    };
    const fingerprintC = {
      name: 'FingerprintC',
      id: '000000',
    };
    const enrollments = [fingerprintC, fingerprintB, fingerprintA];
    const sortedEnrollments = [fingerprintA, fingerprintB, fingerprintC];
    enumerateResolver.resolve(enrollments);
    await uiReady;
    assertShown(allDivs, dialog, 'enrollments');
    assertDeepEquals(dialog.$.enrollmentList.items, sortedEnrollments);

    // Delete the second enrollments and refresh the list.
    flush();
    dialog.$.enrollmentList.querySelectorAll('cr-icon-button')[1]!.click();
    const id = await browserProxy.whenCalled('deleteEnrollment');
    assertEquals(sortedEnrollments[1]!.id, id);
    sortedEnrollments.splice(1, 1);
    enrollments.splice(1, 1);
    deleteResolver.resolve(enrollments);
    await uiReady;
    assertShown(allDivs, dialog, 'enrollments');
    assertDeepEquals(dialog.$.enrollmentList.items, sortedEnrollments);
  });

  test('AddEnrollment', async function() {
    const startResolver = new PromiseResolver();
    browserProxy.setResponseFor('startBioEnroll', startResolver.promise);
    const pinResolver = new PromiseResolver();
    browserProxy.setResponseFor('providePin', pinResolver.promise);
    const getSensorInfoResolver = new PromiseResolver();
    browserProxy.setResponseFor('getSensorInfo', getSensorInfoResolver.promise);
    const enumerateResolver = new PromiseResolver();
    browserProxy.setResponseFor(
        'enumerateEnrollments', enumerateResolver.promise);
    const enrollingResolver = new PromiseResolver();
    browserProxy.setResponseFor('startEnrolling', enrollingResolver.promise);

    document.body.appendChild(dialog);
    await browserProxy.whenCalled('startBioEnroll');
    assertShown(allDivs, dialog, 'initial');

    // Simulate PIN entry.
    let uiReady = eventToPromise('bio-enroll-dialog-ready-for-testing', dialog);
    startResolver.resolve([currentMinPinLength]);
    await uiReady;
    assertShown(allDivs, dialog, 'pinPrompt');
    assertEquals(currentMinPinLength, dialog.$.pin.minPinLength);
    dialog.$.pin.$.pin.value = '000000';
    await dialog.$.pin.$.pin.updateComplete;
    dialog.$.confirmButton.click();
    const pin = await browserProxy.whenCalled('providePin');
    assertEquals(pin, '000000');
    pinResolver.resolve(null);

    await browserProxy.whenCalled('getSensorInfo');
    getSensorInfoResolver.resolve({
      maxTemplateFriendlyName: 20,
    });

    // Ensure no enrollments exist.
    await browserProxy.whenCalled('enumerateEnrollments');
    uiReady = eventToPromise('bio-enroll-dialog-ready-for-testing', dialog);
    enumerateResolver.resolve([]);
    await uiReady;
    assertShown(allDivs, dialog, 'enrollments');
    assertEquals(dialog.$.enrollmentList.items!.length, 0);

    // Simulate add enrollment.
    assertFalse(dialog.$.addButton.hidden);
    uiReady = eventToPromise('bio-enroll-dialog-ready-for-testing', dialog);
    dialog.$.addButton.click();
    await browserProxy.whenCalled('startEnrolling');
    await uiReady;

    assertShown(allDivs, dialog, 'enroll');
    webUIListenerCallback(
        'security-keys-bio-enroll-status',
        {status: SampleStatus.OK, remaining: 1});
    flush();
    assertFalse(dialog.$.arc.isComplete());
    assertFalse(dialog.$.cancelButton.hidden);
    assertTrue(dialog.$.confirmButton.hidden);

    uiReady = eventToPromise('bio-enroll-dialog-ready-for-testing', dialog);
    const enrollmentId = 'someId';
    const enrollmentName = 'New Fingerprint';
    enrollingResolver.resolve({
      code: 0,
      enrollment: {
        id: enrollmentId,
        name: enrollmentName,
      },
    });
    await uiReady;
    assertTrue(dialog.$.arc.isComplete());
    assertTrue(dialog.$.cancelButton.hidden);
    assertFalse(dialog.$.confirmButton.hidden);

    // Proceeding brings up rename dialog page.
    uiReady = eventToPromise('bio-enroll-dialog-ready-for-testing', dialog);
    dialog.$.confirmButton.click();
    await uiReady;

    // Try renaming with a name that's longer than |maxTemplateFriendlyName|.
    assertShown(allDivs, dialog, 'chooseName');
    assertEquals(dialog.$.enrollmentName.value, enrollmentName);
    const invalidNewEnrollmentName = '21 bytes long string!';
    dialog.$.enrollmentName.value = invalidNewEnrollmentName;
    await dialog.$.enrollmentName.updateComplete;
    assertFalse(dialog.$.confirmButton.hidden);
    assertFalse(dialog.$.confirmButton.disabled);
    assertFalse(dialog.$.enrollmentName.invalid);
    dialog.$.confirmButton.click();
    assertTrue(dialog.$.enrollmentName.invalid);
    assertEquals(browserProxy.getCallCount('renameEnrollment'), 0);

    // Try renaming to a valid name.
    assertShown(allDivs, dialog, 'chooseName');
    const newEnrollmentName = '20 bytes long string';
    dialog.$.enrollmentName.value = newEnrollmentName;
    await dialog.$.enrollmentName.updateComplete;
    assertFalse(dialog.$.confirmButton.hidden);
    assertFalse(dialog.$.confirmButton.disabled);

    // Proceeding renames the enrollment and returns to the enrollment overview.
    uiReady = eventToPromise('bio-enroll-dialog-ready-for-testing', dialog);
    const renameEnrollmentResolver = new PromiseResolver();
    browserProxy.setResponseFor(
        'renameEnrollment', renameEnrollmentResolver.promise);
    dialog.$.confirmButton.click();
    assertFalse(dialog.$.enrollmentName.invalid);

    const renameArgs = await browserProxy.whenCalled('renameEnrollment');
    assertDeepEquals(renameArgs, [enrollmentId, newEnrollmentName]);
    renameEnrollmentResolver.resolve([]);
    await uiReady;
    assertShown(allDivs, dialog, 'enrollments');
  });

  test('EnrollCancel', async function() {
    // Simulate starting an enrollment and then cancelling it.
    browserProxy.setResponseFor('enumerateEnrollments', Promise.resolve([]));
    const enrollResolver = new PromiseResolver();
    browserProxy.setResponseFor('startEnrolling', enrollResolver.promise);

    document.body.appendChild(dialog);
    await browserProxy.whenCalled('startBioEnroll');

    dialog.setDialogPageForTesting(BioEnrollDialogPage.ENROLLMENTS);

    // Forcibly disable the cancel button to ensure showing the dialog page
    // re-enables it.
    dialog.setCancelButtonDisabledForTesting(true);

    let uiReady = eventToPromise('bio-enroll-dialog-ready-for-testing', dialog);
    dialog.$.addButton.click();
    await browserProxy.whenCalled('startEnrolling');
    await uiReady;

    assertShown(allDivs, dialog, 'enroll');
    assertFalse(dialog.$.cancelButton.disabled);
    assertFalse(dialog.$.cancelButton.hidden);

    uiReady = eventToPromise('bio-enroll-dialog-ready-for-testing', dialog);
    dialog.$.cancelButton.click();
    await browserProxy.whenCalled('cancelEnrollment');
    enrollResolver.resolve({code: Ctap2Status.ERR_KEEPALIVE_CANCEL});
    await browserProxy.whenCalled('enumerateEnrollments');

    await uiReady;
    assertShown(allDivs, dialog, 'enrollments');
  });

  test('EnrollError', async function() {
    // Test that resolving the startEnrolling promise with a CTAP error brings
    // up the error page.
    const enrollResolver = new PromiseResolver();
    browserProxy.setResponseFor('startEnrolling', enrollResolver.promise);

    document.body.appendChild(dialog);
    await browserProxy.whenCalled('startBioEnroll');

    dialog.setDialogPageForTesting(BioEnrollDialogPage.ENROLLMENTS);

    let uiReady = eventToPromise('bio-enroll-dialog-ready-for-testing', dialog);
    dialog.$.addButton.click();
    await browserProxy.whenCalled('startEnrolling');
    await uiReady;

    uiReady = eventToPromise('bio-enroll-dialog-ready-for-testing', dialog);

    enrollResolver.resolve({code: Ctap2Status.ERR_INVALID_OPTION});

    await uiReady;
    assertShown(allDivs, dialog, 'error');
  });
});