vault/ui/tests/integration/components/mfa-form-test.js
Jordan Reimer 8700becc45
[UI] Ember Data Migration - API Property Casing (#31325)
* updates api client vars to snake_case for custom messages

* updates api client vars to snake_case for tools

* updates api client vars to snake_case for sync

* updates api client vars to snake_case for secrets engine

* updates api client vars to snake_case for auth

* updates api client vars to snake_case for usage

* updates api client dep to point to gh repo

* fixes custom-messages service unit tests

* fixes configure-ssh test

* fixes configure-ssh test...again
2025-07-18 09:32:01 -06:00

305 lines
11 KiB
JavaScript

/**
* 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`<Mfa::MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onError={{fn (mut this.error)}}
@onCancel={{this.onCancel}}
/>`
);
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`<Mfa::MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onError={{fn (mut this.error)}}
@onCancel={{this.onCancel}}
/>`
);
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`<Mfa::MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onError={{fn (mut this.error)}}
@onCancel={{this.onCancel}}
/>`
);
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`<Mfa::MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onCancel={{this.onCancel}}
/>`);
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`<Mfa::MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onSuccess={{this.onSuccess}}
@onCancel={{this.onCancel}}
/>`
);
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`<Mfa::MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onSuccess={{this.onSuccess}}
@onCancel={{this.onCancel}}
/>`
);
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`<Mfa::MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onCancel={{this.onCancel}}
/>`);
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`<Mfa::MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onCancel={{this.onCancel}}
/>`);
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`<Mfa::MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onCancel={{this.onCancel}}
/>`);
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`<Mfa::MfaForm
@clusterId={{this.clusterId}}
@authData={{this.mfaAuthData}}
@onCancel={{this.onCancel}}
/>`);
await click(GENERAL.backButton);
assert.true(this.onCancel.calledOnce, 'it fires onCancel callback');
});
});