/** * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: BUSL-1.1 */ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import { render, settled, fillIn, click, waitUntil, waitFor } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { _cancelTimers as cancelTimers, later } from '@ember/runloop'; import { TOTP_VALIDATION_ERROR } from 'vault/components/mfa/mfa-form'; import sinon from 'sinon'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; module('Integration | Component | mfa-form', function (hooks) { setupRenderingTest(hooks); setupMirage(hooks); hooks.beforeEach(function () { this.onCancel = sinon.spy(); this.clusterId = '123456'; this.mfaAuthData = { authMethodType: 'userpass', authMountPath: 'userpass', }; this.authService = this.owner.lookup('service:auth'); // setup basic totp mfaRequirement // override in tests that require different scenarios this.totpConstraint = this.server.create('mfa-method', { type: 'totp' }); const mfaRequirement = this.authService.parseMfaResponse({ mfa_request_id: 'test-mfa-id', mfa_constraints: { test_mfa: { any: [this.totpConstraint] } }, }); this.mfaAuthData.mfaRequirement = mfaRequirement; }); test('it should render correct descriptions', async function (assert) { const totpConstraint = this.server.create('mfa-method', { type: 'totp' }); const oktaConstraint = this.server.create('mfa-method', { type: 'okta' }); const duoConstraint = this.server.create('mfa-method', { type: 'duo' }); this.mfaAuthData.mfaRequirement = this.authService.parseMfaResponse({ mfa_request_id: 'test-mfa-id', mfa_constraints: { test_mfa_1: { any: [totpConstraint] } }, }); await render( hbs`` ); assert .dom('[data-test-mfa-description]') .includesText( 'Enter your authentication code to log in.', 'Correct description renders for single passcode' ); this.mfaAuthData.mfaRequirement = this.authService.parseMfaResponse({ mfa_request_id: 'test-mfa-id', mfa_constraints: { test_mfa_1: { any: [duoConstraint, oktaConstraint] } }, }); await render( hbs`` ); assert .dom('[data-test-mfa-description]') .includesText( 'Select the MFA method you wish to use.', 'Correct description renders for multiple methods' ); this.mfaAuthData.mfaRequirement = this.authService.parseMfaResponse({ mfa_request_id: 'test-mfa-id', mfa_constraints: { test_mfa_1: { any: [oktaConstraint] }, test_mfa_2: { any: [duoConstraint] } }, }); await render( hbs`` ); assert .dom('[data-test-mfa-description]') .includesText( 'Two methods are required for successful authentication.', 'Correct description renders for multiple constraints' ); }); test('it should render a submit button', async function (assert) { await render(hbs``); assert.dom('[data-test-mfa-validate]').isNotDisabled('Button is not disabled by default'); }); test('it should render method selects and passcode inputs', async function (assert) { assert.expect(2); const duoConstraint = this.server.create('mfa-method', { type: 'duo', uses_passcode: true }); const oktaConstraint = this.server.create('mfa-method', { type: 'okta' }); const pingidConstraint = this.server.create('mfa-method', { type: 'pingid' }); const mfaRequirement = this.authService.parseMfaResponse({ mfa_request_id: 'test-mfa-id', mfa_constraints: { test_mfa_1: { any: [pingidConstraint, oktaConstraint], }, test_mfa_2: { any: [duoConstraint], }, }, }); this.mfaAuthData.mfaRequirement = mfaRequirement; this.server.post('/sys/mfa/validate', (schema, req) => { const json = JSON.parse(req.requestBody); const payload = { mfa_request_id: 'test-mfa-id', mfa_payload: { [oktaConstraint.id]: [], [duoConstraint.id]: ['passcode=test-code'] }, }; assert.deepEqual(json, payload, 'Correct mfa payload passed to validate endpoint'); return {}; }); this.owner.lookup('service:auth').reopen({ // override to avoid authSuccess method since it expects an auth payload async totpValidate({ mfaRequirement }) { await this.clusterAdapter().mfaValidate(mfaRequirement); return 'test response'; }, }); this.onSuccess = (resp) => assert.strictEqual(resp, 'test response', 'Response is returned in onSuccess callback'); await render( hbs`` ); await fillIn('[data-test-mfa-select="0"] select', oktaConstraint.id); await fillIn('[data-test-mfa-passcode="1"]', 'test-code'); await click('[data-test-mfa-validate]'); }); test('it should validate mfa requirement', async function (assert) { assert.expect(5); this.server.post('/sys/mfa/validate', (schema, req) => { const json = JSON.parse(req.requestBody); const payload = { mfa_request_id: 'test-mfa-id', mfa_payload: { [this.totpConstraint.id]: ['test-code'] }, }; assert.deepEqual(json, payload, 'Correct mfa payload passed to validate endpoint'); return {}; }); const expectedAuthData = { clusterId: this.clusterId, ...this.mfaAuthData }; this.owner.lookup('service:auth').reopen({ // override to avoid authSuccess method since it expects an auth payload async totpValidate(authData) { await waitUntil(() => assert .dom('[data-test-mfa-validate] [data-test-icon="loading"]') .exists('Loading icon shows on button') ); assert.dom('[data-test-mfa-validate]').isDisabled('Button is disabled while loading'); assert.deepEqual(authData, expectedAuthData, 'Mfa auth data passed to validate method'); await this.clusterAdapter().mfaValidate(authData.mfaRequirement); return 'test response'; }, }); this.onSuccess = (resp) => assert.strictEqual(resp, 'test response', 'Response is returned in onSuccess callback'); await render( hbs`` ); await fillIn('[data-test-mfa-passcode]', 'test-code'); await click('[data-test-mfa-validate]'); }); test('it should show countdown on passcode already used and rate limit errors', async function (assert) { const messages = { used: 'code already used; new code is available in 30 seconds', // note: the backend returns a duplicate "s" in "30s seconds" in the limit message below. we have intentionally left it as is to ensure our regex for parsing the delay time can handle it limit: 'maximum TOTP validation attempts 4 exceeded the allowed attempts 3. Please try again in 30s seconds', }; const codes = ['used', 'limit']; for (const code of codes) { this.owner.lookup('service:auth').reopen({ totpValidate() { throw { errors: [messages[code]] }; }, }); await render(hbs``); await fillIn('[data-test-mfa-passcode]', 'foo'); await click('[data-test-mfa-validate]'); await waitFor('[data-test-mfa-countdown]'); assert .dom('[data-test-mfa-countdown]') .includesText('30', 'countdown renders with correct initial value from error response'); assert.dom('[data-test-mfa-validate]').isDisabled('Button is disabled during countdown'); assert.dom('[data-test-mfa-passcode]').isDisabled('Input is disabled during countdown'); assert.dom('[data-test-inline-error-message]').exists('Alert message renders'); } }); test('it defaults countdown to 30 seconds if error message does not indicate when user can try again ', async function (assert) { this.owner.lookup('service:auth').reopen({ totpValidate() { throw { errors: ['maximum TOTP validation attempts 4 exceeded the allowed attempts 3. Beep-boop.'], }; }, }); await render(hbs``); await fillIn('[data-test-mfa-passcode]', 'foo'); await click('[data-test-mfa-validate]'); await waitFor('[data-test-mfa-countdown]'); assert .dom('[data-test-mfa-countdown]') .includesText('30', 'countdown renders with correct initial value from error response'); assert.dom('[data-test-mfa-validate]').isDisabled('Button is disabled during countdown'); assert.dom('[data-test-mfa-passcode]').isDisabled('Input is disabled during countdown'); assert.dom('[data-test-inline-error-message]').exists('Alert message renders'); }); test('it should show error message for passcode invalid error', async function (assert) { this.owner.lookup('service:auth').reopen({ totpValidate() { throw { errors: ['failed to validate'] }; }, }); await render(hbs``); await fillIn('[data-test-mfa-passcode]', 'test-code'); later(() => cancelTimers(), 50); await settled(); await click('[data-test-mfa-validate]'); assert .dom('[data-test-message-error]') .includesText(TOTP_VALIDATION_ERROR, 'Generic error message renders for passcode validation error'); }); test('it should call onCancel callback', async function (assert) { await render(hbs``); await click(GENERAL.backButton); assert.true(this.onCancel.calledOnce, 'it fires onCancel callback'); }); });