From 24826743123bd34977f896fc0d2c58cf66c564e5 Mon Sep 17 00:00:00 2001 From: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Date: Thu, 20 Jun 2024 12:40:28 -0700 Subject: [PATCH] UI: Create starter Auth::Page component (#27478) * move OktaNumberChallenge and AuthForm to AuthPage component * return from didReceiveAttrs if component is being torn down * update auth form test * change passed task to an auth action * update auth form unit test * fix return * update jsdoc for auth form * add docs * add comments, last little cleanup, pass API error to okta number challenge * separate tests and move Auth::Page specific logic out of auth form integration test * fix test typos * fix page tests --- ui/app/components/auth-form.js | 103 +---- ui/app/components/auth/page.hbs | 23 ++ ui/app/components/auth/page.js | 108 +++++ ui/app/controllers/vault/cluster/auth.js | 4 - ui/app/templates/components/auth-form.hbs | 368 +++++++++--------- .../components/okta-number-challenge.hbs | 63 ++- ui/app/templates/vault/cluster/auth.hbs | 17 +- ui/tests/helpers/auth/auth-form-selectors.ts | 15 + ui/tests/helpers/general-selectors.ts | 1 + .../integration/components/auth-form-test.js | 292 +++++--------- .../integration/components/auth/page-test.js | 205 ++++++++++ .../components/okta-number-challenge-test.js | 49 +-- ui/tests/unit/components/auth-form-test.js | 33 +- 13 files changed, 709 insertions(+), 572 deletions(-) create mode 100644 ui/app/components/auth/page.hbs create mode 100644 ui/app/components/auth/page.js create mode 100644 ui/tests/helpers/auth/auth-form-selectors.ts create mode 100644 ui/tests/integration/components/auth/page-test.js diff --git a/ui/app/components/auth-form.js b/ui/app/components/auth-form.js index cf7e93102c951..d87161b29048b 100644 --- a/ui/app/components/auth-form.js +++ b/ui/app/components/auth-form.js @@ -3,7 +3,6 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import Ember from 'ember'; import { next } from '@ember/runloop'; import { service } from '@ember/service'; import { match, or } from '@ember/object/computed'; @@ -11,27 +10,25 @@ import { dasherize } from '@ember/string'; import Component from '@ember/component'; import { computed } from '@ember/object'; import { allSupportedAuthBackends, supportedAuthBackends } from 'vault/helpers/supported-auth-backends'; -import { task, timeout } from 'ember-concurrency'; +import { task } from 'ember-concurrency'; import { waitFor } from '@ember/test-waiters'; import { v4 as uuidv4 } from 'uuid'; /** * @module AuthForm - * The `AuthForm` is used to sign users into Vault. + * The AuthForm displays the form used to sign users into Vault and passes input data to the Auth::Page component which handles authentication * - * @example ```js - * // All properties are passed in via query params. - * ``` + * @example + * * - * @param {string} wrappedToken - The auth method that is currently selected in the dropdown. - * @param {object} cluster - The auth method that is currently selected in the dropdown. This corresponds to an Ember Model. + * @param {string} wrappedToken - Token that can be used to login if added directly to the URL via the "wrapped_token" query param + * @param {object} cluster - The cluster model which contains information such as cluster id, name and boolean for if the cluster is in standby * @param {string} namespace- The currently active namespace. * @param {string} selectedAuth - The auth method that is currently selected in the dropdown. - * @param {function} onSuccess - Fired on auth success. - * @param {function} [setOktaNumberChallenge] - Sets whether we are waiting for okta number challenge to be used to sign in. - * @param {boolean} [waitingForOktaNumberChallenge=false] - Determines if we are waiting for the Okta Number Challenge to sign in. - * @param {function} [setCancellingAuth] - Sets whether we are cancelling or not the login authentication for Okta Number Challenge. - * @param {boolean} [cancelAuthForOktaNumberChallenge=false] - Determines if we are cancelling the login authentication for the Okta Number Challenge. + * @param {function} performAuth - Callback that triggers authenticate task in the parent, backend type (i.e. 'okta') and relevant auth data are passed as args + * @param {string} error - Error returned by the parent authenticate task, message is generated by the auth service handleError method + * @param {boolean} authIsRunning - Boolean that relays whether or not the authenticate task is running + * @param {boolean} delayIsIdle - Boolean that relays whether or not the delayAuthMessageReminder parent task is idle */ const DEFAULTS = { @@ -49,7 +46,7 @@ export default Component.extend(DEFAULTS, { csp: service('csp-event'), version: service(), - // passed in via a query param + // set by query params, passed from parent Auth::Page component selectedAuth: null, methods: null, cluster: null, @@ -58,9 +55,6 @@ export default Component.extend(DEFAULTS, { // internal oldNamespace: null, - // number answer for okta number challenge if applicable - oktaNumberChallengeAnswer: null, - authMethods: computed('version.isEnterprise', function () { return this.version.isEnterprise ? allSupportedAuthBackends() : supportedAuthBackends(); }), @@ -74,18 +68,13 @@ export default Component.extend(DEFAULTS, { namespace: ns, selectedAuth: newMethod, oldSelectedAuth: oldMethod, - cancelAuthForOktaNumberChallenge: cancelAuth, } = this; - // if we are cancelling the login then we reset the number challenge answer and cancel the current authenticate and polling tasks - if (cancelAuth) { - this.set('oktaNumberChallengeAnswer', null); - this.authenticate.cancelAll(); - this.pollForOktaNumberChallenge.cancelAll(); - } next(() => { if (!token && (oldNS === null || oldNS !== ns)) { this.fetchMethods.perform(); } + // don't set any variables if the component is being torn down + if (this.isDestroyed || this.isDestroying) return; this.set('oldNamespace', ns); // we only want to trigger this once if (token && !oldToken) { @@ -235,65 +224,7 @@ export default Component.extend(DEFAULTS, { }) ), - showLoading: or('isLoading', 'authenticate.isRunning', 'fetchMethods.isRunning', 'unwrapToken.isRunning'), - - authenticate: task( - waitFor(function* (backendType, data) { - const { - selectedAuth, - cluster: { id: clusterId }, - } = this; - try { - if (backendType === 'okta') { - this.pollForOktaNumberChallenge.perform(data.nonce, data.path); - } else { - this.delayAuthMessageReminder.perform(); - } - const authResponse = yield this.auth.authenticate({ - clusterId, - backend: backendType, - data, - selectedAuth, - }); - this.onSuccess(authResponse, backendType, data); - } catch (e) { - this.set('isLoading', false); - if (!this.auth.mfaError) { - this.set('error', `Authentication failed: ${this.auth.handleError(e)}`); - } - } - }) - ), - - pollForOktaNumberChallenge: task(function* (nonce, mount) { - // yield for 1s to wait to see if there is a login error before polling - yield timeout(1000); - if (this.error) { - return; - } - let response = null; - this.setOktaNumberChallenge(true); - this.setCancellingAuth(false); - // keep polling /auth/okta/verify/:nonce API every 1s until a response is given with the correct number for the Okta Number Challenge - while (response === null) { - // when testing, the polling loop causes promises to be rejected making acceptance tests fail - // so disable the poll in tests - if (Ember.testing) { - return; - } - yield timeout(1000); - response = yield this.auth.getOktaNumberChallengeAnswer(nonce, mount); - } - this.set('oktaNumberChallengeAnswer', response); - }), - - delayAuthMessageReminder: task(function* () { - if (Ember.testing) { - yield timeout(0); - } else { - yield timeout(5000); - } - }), + showLoading: or('isLoading', 'authIsRunning', 'fetchMethods.isRunning', 'unwrapToken.isRunning'), actions: { doSubmit(passedData, event, token) { @@ -326,7 +257,7 @@ export default Component.extend(DEFAULTS, { data.path = 'okta'; } } - return this.authenticate.unlinked().perform(backend.type, data); + return this.performAuth(backend.type, data); }, handleError(e) { this.setProperties({ @@ -334,9 +265,5 @@ export default Component.extend(DEFAULTS, { error: e ? this.auth.handleError(e) : null, }); }, - returnToLoginFromOktaNumberChallenge() { - this.setOktaNumberChallenge(false); - this.set('oktaNumberChallengeAnswer', null); - }, }, }); diff --git a/ui/app/components/auth/page.hbs b/ui/app/components/auth/page.hbs new file mode 100644 index 0000000000000..e8b4d0c2e2e70 --- /dev/null +++ b/ui/app/components/auth/page.hbs @@ -0,0 +1,23 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{#if this.waitingForOktaNumberChallenge}} + +{{else}} + +{{/if}} \ No newline at end of file diff --git a/ui/app/components/auth/page.js b/ui/app/components/auth/page.js new file mode 100644 index 0000000000000..83fb397116e8c --- /dev/null +++ b/ui/app/components/auth/page.js @@ -0,0 +1,108 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import Ember from 'ember'; +import { service } from '@ember/service'; +import { task, timeout } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +/** + * @module AuthPage + * The Auth::Page wraps OktaNumberChallenge and AuthForm to manage the login flow and is responsible for calling the authenticate method + * + * @example + * + * + * @param {string} wrappedToken - Query param value of a wrapped token that can be used to login when added directly to the URL via the "wrapped_token" query param + * @param {object} cluster - The route model which is the ember data cluster model. contains information such as cluster id, name and boolean for if the cluster is in standby + * @param {string} namespace- Namespace query param, passed to AuthForm and set by typing in namespace input or URL + * @param {string} selectedAuth - The auth method selected in the dropdown, passed to auth service's authenticate method + * @param {function} onSuccess - Callback that fires the "onAuthResponse" action in the auth controller and handles transitioning after success + */ + +export default class AuthPageComponent extends Component { + @service auth; + + @tracked authError = null; + @tracked oktaNumberChallengeAnswer = ''; + @tracked waitingForOktaNumberChallenge = false; + + @action + performAuth(backendType, data) { + this.authenticate.unlinked().perform(backendType, data); + } + + @task + @waitFor + *delayAuthMessageReminder() { + if (Ember.testing) { + yield timeout(0); + } else { + yield timeout(5000); + } + } + + @task + @waitFor + *authenticate(backendType, data) { + const { + selectedAuth, + cluster: { id: clusterId }, + } = this.args; + try { + if (backendType === 'okta') { + this.pollForOktaNumberChallenge.perform(data.nonce, data.path); + } else { + this.delayAuthMessageReminder.perform(); + } + const authResponse = yield this.auth.authenticate({ + clusterId, + backend: backendType, + data, + selectedAuth, + }); + + this.args.onSuccess(authResponse, backendType, data); + } catch (e) { + if (!this.auth.mfaError) { + this.authError = `Authentication failed: ${this.auth.handleError(e)}`; + } + } + } + + @task + @waitFor + *pollForOktaNumberChallenge(nonce, mount) { + // yield for 1s to wait to see if there is a login error before polling + yield timeout(1000); + if (this.authError) return; + + this.waitingForOktaNumberChallenge = true; + // keep polling /auth/okta/verify/:nonce API every 1s until response returns with correct_number + let response = null; + while (response === null) { + // disable polling for tests otherwise promises reject and acceptance tests fail + if (Ember.testing) return; + + yield timeout(1000); + response = yield this.auth.getOktaNumberChallengeAnswer(nonce, mount); + } + // display correct number so user can select on personal MFA device + this.oktaNumberChallengeAnswer = response; + } + + @action + onCancel() { + // reset variables and stop polling tasks if canceling login + this.authError = null; + this.oktaNumberChallengeAnswer = null; + this.waitingForOktaNumberChallenge = false; + this.authenticate.cancelAll(); + this.pollForOktaNumberChallenge.cancelAll(); + } +} diff --git a/ui/app/controllers/vault/cluster/auth.js b/ui/app/controllers/vault/cluster/auth.js index 75c5cf52114fd..66a798cac0cd2 100644 --- a/ui/app/controllers/vault/cluster/auth.js +++ b/ui/app/controllers/vault/cluster/auth.js @@ -101,9 +101,5 @@ export default Controller.extend({ mfaErrors: null, }); }, - cancelAuthentication() { - this.set('cancelAuth', true); - this.set('waitingForOktaNumberChallenge', false); - }, }, }); diff --git a/ui/app/templates/components/auth-form.hbs b/ui/app/templates/components/auth-form.hbs index 66f135f2e9404..f9fee4c71ff3a 100644 --- a/ui/app/templates/components/auth-form.hbs +++ b/ui/app/templates/components/auth-form.hbs @@ -3,202 +3,192 @@ SPDX-License-Identifier: BUSL-1.1 ~}}
- {{#if (and this.waitingForOktaNumberChallenge (not this.cancelAuthForOktaNumberChallenge))}} - - {{else}} - {{#if this.hasMethodsWithPath}} - + + {{or method.id (capitalize method.type)}} + + + {{/let}} + {{/each}} +
  • + + Other + +
  • + + + {{/if}} +
    + + {{#if this.selectedAuthBackend.path}} +
    +

    {{this.selectedAuthBackend.path}}

    + + {{this.selectedAuthBackend.mountDescription}} + +
    {{/if}} -
    - - {{#if this.selectedAuthBackend.path}} -
    -

    {{this.selectedAuthBackend.path}}

    - - {{this.selectedAuthBackend.mountDescription}} - -
    - {{/if}} - {{#if (or (not this.hasMethodsWithPath) (not this.selectedAuthIsPath))}} - +
    +
    + {{else if (eq this.providerName "token")}} +
    + +
    + +
    +
    + {{else}} +
    + +
    + +
    +
    +
    + +
    + +
    +
    + {{/if}} + {{#if (not-eq this.selectedAuthBackend.type "token")}} - - {{else}} -
    - {{#if (eq this.providerName "github")}} -
    - -
    - -
    -
    - {{else if (eq this.providerName "token")}} -
    - -
    - -
    -
    - {{else}} -
    - -
    - -
    -
    -
    - -
    - -
    -
    - {{/if}} - {{#if (not-eq this.selectedAuthBackend.type "token")}} - - {{/if}} - + {{#if (and this.delayIsIdle this.showLoading)}} + - {{#if (and this.delayAuthMessageReminder.isIdle this.showLoading)}} - - {{/if}} - - {{/if}} -
    - {{/if}} + {{/if}} + + {{/if}} + \ No newline at end of file diff --git a/ui/app/templates/components/okta-number-challenge.hbs b/ui/app/templates/components/okta-number-challenge.hbs index 8f08fbc571ba1..4064a7dc7cf1b 100644 --- a/ui/app/templates/components/okta-number-challenge.hbs +++ b/ui/app/templates/components/okta-number-challenge.hbs @@ -2,42 +2,35 @@ Copyright (c) HashiCorp, Inc. SPDX-License-Identifier: BUSL-1.1 ~}} - +{{! todo move to auth/ folder? }}
    -
    -

    - To finish signing in, you will need to complete an additional MFA step.

    - {{#if @hasError}} -
    - - -
    - {{else if @correctAnswer}} -
    -

    Okta - verification

    -

    Select the following number to complete verification:

    -

    {{@correctAnswer}}

    -
    - {{else}} -
    -
    - -
    -

    Please wait...

    -
    -
    -
    - {{/if}} -
    +

    + To finish signing in, you will need to complete an additional MFA step. +

    + {{#if @hasError}} + + {{else if @correctAnswer}} +

    + Okta verification +

    +

    Select the following number to complete verification:

    +

    + {{@correctAnswer}} +

    + {{else}} +
    + +

    Please wait...

    +
    + {{/if}}
    +
    \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/auth.hbs b/ui/app/templates/vault/cluster/auth.hbs index cb40274168788..db28f398c5ca1 100644 --- a/ui/app/templates/vault/cluster/auth.hbs +++ b/ui/app/templates/vault/cluster/auth.hbs @@ -38,17 +38,9 @@ @color="tertiary" {{on "click" (fn (mut this.mfaAuthData) null)}} /> - {{else if this.waitingForOktaNumberChallenge}} - {{/if}}

    - {{if (or this.mfaAuthData this.waitingForOktaNumberChallenge) "Authenticate" "Sign in to Vault"}} + {{if this.mfaAuthData "Authenticate" "Sign in to Vault"}}

    {{/if}} @@ -101,17 +93,12 @@ @onError={{fn (mut this.mfaErrors)}} /> {{else}} - {{/if}} diff --git a/ui/tests/helpers/auth/auth-form-selectors.ts b/ui/tests/helpers/auth/auth-form-selectors.ts new file mode 100644 index 0000000000000..a266fe86bdb0e --- /dev/null +++ b/ui/tests/helpers/auth/auth-form-selectors.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +export const AUTH_FORM = { + form: '[data-test-auth-form]', + login: '[data-test-auth-submit]', + tabs: (method: string) => (method ? `[data-test-auth-method="${method}"]` : '[data-test-auth-method]'), + description: '[data-test-description]', + roleInput: '[data-test-role]', + input: (item: string) => `[data-test-${item}]`, // i.e. role, token, password or username + mountPathInput: '[data-test-auth-form-mount-path]', + moreOptions: '[data-test-auth-form-options-toggle]', +}; diff --git a/ui/tests/helpers/general-selectors.ts b/ui/tests/helpers/general-selectors.ts index 9a0421492722b..44294db7adbe7 100644 --- a/ui/tests/helpers/general-selectors.ts +++ b/ui/tests/helpers/general-selectors.ts @@ -17,6 +17,7 @@ export const GENERAL = { secretTab: (name: string) => `[data-test-secret-list-tab="${name}"]`, flashMessage: '[data-test-flash-message]', latestFlashContent: '[data-test-flash-message]:last-of-type [data-test-flash-message-body]', + inlineAlert: '[data-test-inline-alert]', filter: (name: string) => `[data-test-filter="${name}"]`, filterInput: '[data-test-filter-input]', diff --git a/ui/tests/integration/components/auth-form-test.js b/ui/tests/integration/components/auth-form-test.js index 1cb4ef6b051ab..07060597b3db0 100644 --- a/ui/tests/integration/components/auth-form-test.js +++ b/ui/tests/integration/components/auth-form-test.js @@ -4,172 +4,120 @@ */ import { later, _cancelTimers as cancelTimers } from '@ember/runloop'; -import EmberObject from '@ember/object'; -import { resolve } from 'rsvp'; -import Service from '@ember/service'; import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render, settled } from '@ember/test-helpers'; +import { click, fillIn, render, settled } from '@ember/test-helpers'; import hbs from 'htmlbars-inline-precompile'; import sinon from 'sinon'; -import { create } from 'ember-cli-page-object'; -import authForm from '../../pages/components/auth-form'; -import { validate } from 'uuid'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { Response } from 'miragejs'; - -const component = create(authForm); - -const workingAuthService = Service.extend({ - authenticate() { - return resolve({}); - }, - handleError() {}, - setLastFetch() {}, -}); - -const routerService = Service.extend({ - transitionTo() { - return { - followRedirects() { - return resolve(); - }, - }; - }, -}); +import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; module('Integration | Component | auth form', function (hooks) { setupRenderingTest(hooks); setupMirage(hooks); hooks.beforeEach(function () { - this.owner.register('service:router', routerService); this.router = this.owner.lookup('service:router'); - this.onSuccess = sinon.spy(); + this.selectedAuth = 'token'; + this.performAuth = sinon.spy(); + this.renderComponent = async () => { + return render(hbs` + `); + }; }); - const CSP_ERR_TEXT = `Error This is a standby Vault node but can't communicate with the active node via request forwarding. Sign in at the active node to use the Vault UI.`; - test('it renders error on CSP violation', async function (assert) { - assert.expect(2); - this.set('cluster', EmberObject.create({ standby: true })); - this.set('selectedAuth', 'token'); - await render(hbs``); - assert.false(component.errorMessagePresent, false); - this.owner.lookup('service:csp-event').handleEvent({ violatedDirective: 'connect-src' }); - await settled(); - assert.strictEqual(component.errorText, CSP_ERR_TEXT); + test('it calls performAuth on submit', async function (assert) { + await this.renderComponent(); + await fillIn(AUTH_FORM.input('token'), '123token'); + await click(AUTH_FORM.login); + const [type, data] = this.performAuth.lastCall.args; + assert.strictEqual(type, 'token', 'performAuth is called with type'); + assert.propEqual(data, { token: '123token' }, 'performAuth is called with data'); }); - test('it renders with vault style errors', async function (assert) { - assert.expect(1); - this.server.get('/auth/token/lookup-self', () => { - return new Response(400, { 'Content-Type': 'application/json' }, { errors: ['Not allowed'] }); - }); - - this.set('cluster', EmberObject.create({})); - this.set('selectedAuth', 'token'); - await render(hbs``); - await component.login(); - assert.strictEqual(component.errorText, 'Error Authentication failed: Not allowed'); + test('it disables sign in button when authIsRunning', async function (assert) { + this.authIsRunning = true; + await this.renderComponent(); + assert.dom(AUTH_FORM.login).isDisabled('sign in button is disabled'); + assert.dom(`${AUTH_FORM.login} [data-test-icon="loading"]`).exists('sign in button renders loading icon'); }); - test('it renders AdapterError style errors', async function (assert) { - assert.expect(1); - this.server.get('/auth/token/lookup-self', () => { - return new Response(400, { 'Content-Type': 'application/json' }, { errors: ['API Error here'] }); - }); - - this.set('cluster', EmberObject.create({})); - this.set('selectedAuth', 'token'); - await render(hbs``); - return component.login().then(() => { - assert.strictEqual( - component.errorText, - 'Error Authentication failed: API Error here', - 'shows the error from the API' + test('it renders alert info message when delayIsIdle', async function (assert) { + this.delayIsIdle = true; + this.authIsRunning = true; + await this.renderComponent(); + assert + .dom(GENERAL.inlineAlert) + .hasText( + 'If login takes longer than usual, you may need to check your device for an MFA notification, or contact your administrator if login times out.' ); - }); }); test('it renders no tabs when no methods are passed', async function (assert) { - const methods = { - 'approle/': { - type: 'approle', - }, - }; this.server.get('/sys/internal/ui/mounts', () => { - return { data: { auth: methods } }; + return { + data: { + auth: { + 'approle/': { + type: 'approle', + }, + }, + }, + }; }); - await render(hbs``); + await this.renderComponent(); - assert.strictEqual(component.tabs.length, 0, 'renders a tab for every backend'); - server.shutdown(); + assert.dom(AUTH_FORM.tabs()).doesNotExist(); }); test('it renders all the supported methods and Other tab when methods are present', async function (assert) { - const methods = { - 'foo/': { - type: 'userpass', - }, - 'approle/': { - type: 'approle', - }, - }; this.server.get('/sys/internal/ui/mounts', () => { - return { data: { auth: methods } }; + return { + data: { + auth: { + 'foo/': { + type: 'userpass', + }, + 'approle/': { + type: 'approle', + }, + }, + }, + }; }); - this.set('cluster', EmberObject.create({})); - await render(hbs``); - - assert.strictEqual(component.tabs.length, 2, 'renders a tab for userpass and Other'); - assert.strictEqual(component.tabs.objectAt(0).name, 'foo', 'uses the path in the label'); - assert.strictEqual(component.tabs.objectAt(1).name, 'Other', 'second tab is the Other tab'); - }); - test('it renders the description', async function (assert) { - const methods = { - 'approle/': { - type: 'userpass', - description: 'app description', - }, - }; - this.server.get('/sys/internal/ui/mounts', () => { - return { data: { auth: methods } }; - }); - this.set('cluster', EmberObject.create({})); - await render(hbs``); + await this.renderComponent(); - assert.strictEqual( - component.descriptionText, - 'app description', - 'renders a description for auth methods' - ); + assert.dom(AUTH_FORM.tabs()).exists({ count: 2 }); + assert.dom(AUTH_FORM.tabs('foo')).exists('tab uses the path in the label'); + assert.dom(AUTH_FORM.tabs('other')).exists('second tab is the Other tab'); }); - test('it calls authenticate with the correct path', async function (assert) { - this.owner.unregister('service:auth'); - this.owner.register('service:auth', workingAuthService); - this.auth = this.owner.lookup('service:auth'); - const authSpy = sinon.spy(this.auth, 'authenticate'); - const methods = { - 'foo/': { - type: 'userpass', - }, - }; + test('it renders the description', async function (assert) { + this.selectedAuth = null; this.server.get('/sys/internal/ui/mounts', () => { - return { data: { auth: methods } }; + return { + data: { + auth: { + 'approle/': { + type: 'userpass', + description: 'app description', + }, + }, + }, + }; }); - - this.set('cluster', EmberObject.create({})); - this.set('selectedAuth', 'foo/'); - await render(hbs``); - await component.login(); - - await settled(); - assert.ok(authSpy.calledOnce, 'a call to authenticate was made'); - const { data } = authSpy.getCall(0).args[0]; - assert.strictEqual(data.path, 'foo', 'uses the id for the path'); - authSpy.restore(); + await this.renderComponent(); + assert.dom(AUTH_FORM.description).hasText('app description'); }); test('it renders no tabs when no supported methods are present in passed methods', async function (assert) { @@ -181,45 +129,14 @@ module('Integration | Component | auth form', function (hooks) { this.server.get('/sys/internal/ui/mounts', () => { return { data: { auth: methods } }; }); - this.set('cluster', EmberObject.create({})); - await render(hbs``); + await this.renderComponent(); - server.shutdown(); - assert.strictEqual(component.tabs.length, 0, 'renders a tab for every backend'); - }); - - test('it makes a request to unwrap if passed a wrappedToken and logs in', async function (assert) { - assert.expect(3); - this.owner.register('service:auth', workingAuthService); - this.auth = this.owner.lookup('service:auth'); - const authSpy = sinon.stub(this.auth, 'authenticate'); - this.server.post('/sys/wrapping/unwrap', (_, req) => { - assert.strictEqual(req.url, '/v1/sys/wrapping/unwrap', 'makes call to unwrap the token'); - assert.strictEqual( - req.requestHeaders['X-Vault-Token'], - wrappedToken, - 'uses passed wrapped token for the unwrap' - ); - return { - auth: { - client_token: '12345', - }, - }; - }); - - const wrappedToken = '54321'; - this.set('wrappedToken', wrappedToken); - this.set('cluster', EmberObject.create({})); - await render( - hbs`` - ); - later(() => cancelTimers(), 50); - await settled(); - assert.ok(authSpy.calledOnce, 'a call to authenticate was made'); - authSpy.restore(); + assert.dom(AUTH_FORM.tabs()).doesNotExist(); }); test('it shows an error if unwrap errors', async function (assert) { + assert.expect(1); + this.wrappedToken = '54321'; this.server.post('/sys/wrapping/unwrap', () => { return new Response( 400, @@ -228,16 +145,11 @@ module('Integration | Component | auth form', function (hooks) { ); }); - this.set('wrappedToken', '54321'); - await render(hbs``); + await this.renderComponent(); later(() => cancelTimers(), 50); await settled(); - assert.strictEqual( - component.errorText, - 'Error Token unwrap failed: There was an error unwrapping!', - 'shows the error' - ); + assert.dom(GENERAL.messageError).hasText('Error Token unwrap failed: There was an error unwrapping!'); }); test('it should retain oidc role when mount path is changed', async function (assert) { @@ -269,36 +181,14 @@ module('Integration | Component | auth form', function (hooks) { }, }); - this.set('cluster', EmberObject.create({})); - await render(hbs``); - - await component.selectMethod('oidc'); - await component.oidcRole('foo'); - await component.oidcMoreOptions(); - await component.oidcMountPath('foo-oidc'); - assert.dom('[data-test-role]').hasValue('foo', 'role is retained when mount path is changed'); - await component.login(); - }); - - test('it should set nonce value as uuid for okta method type', async function (assert) { - assert.expect(1); - - this.server.post('/auth/okta/login/foo', (_, req) => { - const { nonce } = JSON.parse(req.requestBody); - assert.true(validate(nonce), 'Nonce value passed as uuid for okta login'); - return { - auth: { - client_token: '12345', - }, - }; - }); - - this.set('cluster', EmberObject.create({})); - await render(hbs``); + await this.renderComponent(); - await component.selectMethod('okta'); - await component.username('foo'); - await component.password('bar'); - await component.login(); + await fillIn(GENERAL.selectByAttr('auth-method'), 'oidc'); + await fillIn(AUTH_FORM.input('role'), 'foo'); + await click(AUTH_FORM.moreOptions); + await fillIn(AUTH_FORM.input('role'), 'foo'); + await fillIn(AUTH_FORM.mountPathInput, 'foo-oidc'); + assert.dom(AUTH_FORM.input('role')).hasValue('foo', 'role is retained when mount path is changed'); + await click(AUTH_FORM.login); }); }); diff --git a/ui/tests/integration/components/auth/page-test.js b/ui/tests/integration/components/auth/page-test.js new file mode 100644 index 0000000000000..b9b55abd4ddf5 --- /dev/null +++ b/ui/tests/integration/components/auth/page-test.js @@ -0,0 +1,205 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { later, _cancelTimers as cancelTimers } from '@ember/runloop'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { click, fillIn, render, settled } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import sinon from 'sinon'; +import { validate } from 'uuid'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { Response } from 'miragejs'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; + +module('Integration | Component | auth | page ', function (hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.router = this.owner.lookup('service:router'); + this.auth = this.owner.lookup('service:auth'); + this.cluster = { id: '1' }; + this.selectedAuth = 'token'; + this.onSuccess = sinon.spy(); + + this.renderComponent = async () => { + return render(hbs` + + `); + }; + }); + const CSP_ERR_TEXT = `Error This is a standby Vault node but can't communicate with the active node via request forwarding. Sign in at the active node to use the Vault UI.`; + test('it renders error on CSP violation', async function (assert) { + assert.expect(2); + this.cluster.standby = true; + await this.renderComponent(); + assert.dom(GENERAL.messageError).doesNotExist(); + this.owner.lookup('service:csp-event').handleEvent({ violatedDirective: 'connect-src' }); + await settled(); + assert.dom(GENERAL.messageError).hasText(CSP_ERR_TEXT); + }); + + test('it renders with vault style errors', async function (assert) { + assert.expect(1); + this.server.get('/auth/token/lookup-self', () => { + return new Response(400, { 'Content-Type': 'application/json' }, { errors: ['Not allowed'] }); + }); + + await this.renderComponent(); + await click(AUTH_FORM.login); + assert.dom(GENERAL.messageError).hasText('Error Authentication failed: Not allowed'); + }); + + test('it renders AdapterError style errors', async function (assert) { + assert.expect(1); + this.server.get('/auth/token/lookup-self', () => { + return new Response(400, { 'Content-Type': 'application/json' }, { errors: ['API Error here'] }); + }); + + await this.renderComponent(); + await click(AUTH_FORM.login); + assert + .dom(GENERAL.messageError) + .hasText('Error Authentication failed: API Error here', 'shows the error from the API'); + }); + + test('it calls auth service authenticate method with expected args', async function (assert) { + assert.expect(1); + const authenticateStub = sinon.stub(this.auth, 'authenticate'); + this.selectedAuth = 'foo/'; // set to a non-default path + this.server.get('/sys/internal/ui/mounts', () => { + return { + data: { + auth: { + 'foo/': { + type: 'userpass', + }, + }, + }, + }; + }); + + await this.renderComponent(); + await fillIn(AUTH_FORM.input('username'), 'sandy'); + await fillIn(AUTH_FORM.input('password'), '1234'); + await click(AUTH_FORM.login); + const [actual] = authenticateStub.lastCall.args; + const expectedArgs = { + backend: 'userpass', + clusterId: '1', + data: { + username: 'sandy', + password: '1234', + path: 'foo', + }, + selectedAuth: 'foo/', + }; + assert.propEqual( + actual, + expectedArgs, + `it calls auth service authenticate method with expected args: ${JSON.stringify(actual)} ` + ); + }); + + test('it calls onSuccess with expected args', async function (assert) { + assert.expect(3); + this.server.get(`auth/token/lookup-self`, () => { + return { + data: { + policies: ['default'], + }, + }; + }); + + await this.renderComponent(); + await fillIn(AUTH_FORM.input('token'), 'mytoken'); + await click(AUTH_FORM.login); + const [authResponse, backendType, data] = this.onSuccess.lastCall.args; + const expected = { isRoot: false, namespace: '', token: 'vault-tokenā˜ƒ1' }; + + assert.propEqual( + authResponse, + expected, + `it calls onSuccess with response: ${JSON.stringify(authResponse)} ` + ); + assert.strictEqual(backendType, 'token', `it calls onSuccess with backend type: ${backendType}`); + assert.propEqual(data, { token: 'mytoken' }, `it calls onSuccess with data: ${JSON.stringify(data)}`); + }); + + test('it makes a request to unwrap if passed a wrappedToken and logs in', async function (assert) { + assert.expect(3); + const authenticateStub = sinon.stub(this.auth, 'authenticate'); + this.wrappedToken = '54321'; + + this.server.post('/sys/wrapping/unwrap', (_, req) => { + assert.strictEqual(req.url, '/v1/sys/wrapping/unwrap', 'makes call to unwrap the token'); + assert.strictEqual( + req.requestHeaders['X-Vault-Token'], + this.wrappedToken, + 'uses passed wrapped token for the unwrap' + ); + return { + auth: { + client_token: '12345', + }, + }; + }); + + await this.renderComponent(); + later(() => cancelTimers(), 50); + await settled(); + const [actual] = authenticateStub.lastCall.args; + assert.propEqual( + actual, + { + backend: 'token', + clusterId: '1', + data: { + token: '12345', + }, + selectedAuth: 'token', + }, + `it calls auth service authenticate method with correct args: ${JSON.stringify(actual)} ` + ); + }); + + test('it should set nonce value as uuid for okta method type', async function (assert) { + assert.expect(4); + this.server.post('/auth/okta/login/foo', (_, req) => { + const { nonce } = JSON.parse(req.requestBody); + assert.true(validate(nonce), 'Nonce value passed as uuid for okta login'); + return { + auth: { + client_token: '12345', + policies: ['default'], + }, + }; + }); + + await this.renderComponent(); + + await fillIn(GENERAL.selectByAttr('auth-method'), 'okta'); + await fillIn(AUTH_FORM.input('username'), 'foo'); + await fillIn(AUTH_FORM.input('password'), 'bar'); + await click(AUTH_FORM.login); + assert + .dom('[data-test-okta-number-challenge]') + .hasText( + 'To finish signing in, you will need to complete an additional MFA step. Please wait... Back to login', + 'renders okta number challenge on submit' + ); + await click('[data-test-back-button]'); + assert.dom(AUTH_FORM.form).exists('renders auth form on return to login'); + assert.dom(GENERAL.selectByAttr('auth-method')).hasValue('okta', 'preserves method type on back'); + }); +}); diff --git a/ui/tests/integration/components/okta-number-challenge-test.js b/ui/tests/integration/components/okta-number-challenge-test.js index d1871dd5b5b4f..2c57009258e13 100644 --- a/ui/tests/integration/components/okta-number-challenge-test.js +++ b/ui/tests/integration/components/okta-number-challenge-test.js @@ -7,32 +7,40 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import { render, click } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import sinon from 'sinon'; -module('Integration | Component | okta-number-challenge', function (hooks) { +module('Integration | Component | auth | okta-number-challenge', function (hooks) { setupRenderingTest(hooks); hooks.beforeEach(function () { this.oktaNumberChallengeAnswer = null; this.hasError = false; + this.onCancel = sinon.spy(); + this.renderComponent = async () => { + return render(hbs` + + `); + }; }); test('it should render correct descriptions', async function (assert) { - await render(hbs``); - + await this.renderComponent(); assert .dom('[data-test-okta-number-challenge-description]') .includesText( 'To finish signing in, you will need to complete an additional MFA step.', 'Correct description renders' ); - assert - .dom('[data-test-okta-number-challenge-loading]') - .includesText('Please wait...', 'Correct loading description renders'); + assert.dom('[data-test-loading]').includesText('Please wait...', 'Correct loading description renders'); }); test('it should show correct number for okta number challenge', async function (assert) { - this.set('oktaNumberChallengeAnswer', 1); - await render(hbs``); + this.oktaNumberChallengeAnswer = 1; + await this.renderComponent(); assert .dom('[data-test-okta-number-challenge-description]') .includesText( @@ -40,35 +48,30 @@ module('Integration | Component | okta-number-challenge', function (hooks) { 'Correct description renders' ); assert - .dom('[data-test-okta-number-challenge-verification-type]') + .dom('[data-test-verification-type]') .includesText('Okta verification', 'Correct verification type renders'); assert - .dom('[data-test-okta-number-challenge-verification-description]') + .dom('[data-test-description]') .includesText( 'Select the following number to complete verification:', 'Correct verification description renders' ); - assert - .dom('[data-test-okta-number-challenge-answer]') - .includesText('1', 'Correct okta number challenge answer renders'); + assert.dom('[data-test-answer]').includesText('1', 'Correct okta number challenge answer renders'); }); test('it should show error screen', async function (assert) { - this.set('hasError', true); - await render( - hbs`` - ); + this.hasError = 'Authentication failed: multi-factor authentication denied'; + await this.renderComponent(); + assert .dom('[data-test-okta-number-challenge-description]') - .includesText( + .hasTextContaining( 'To finish signing in, you will need to complete an additional MFA step.', 'Correct description renders' ); - assert - .dom('[data-test-message-error]') - .includesText('There was a problem', 'Displays error that there was a problem'); - await click('[data-test-return-from-okta-number-challenge]'); - assert.true(this.returnToLogin, 'onReturnToLogin was triggered'); + assert.dom('[data-test-message-error]').hasText(`Error ${this.hasError}`); + await click('[data-test-back-button]'); + assert.true(this.onCancel.calledOnce, 'onCancel is called'); }); }); diff --git a/ui/tests/unit/components/auth-form-test.js b/ui/tests/unit/components/auth-form-test.js index 81113101aa35e..a054a6e2cb236 100644 --- a/ui/tests/unit/components/auth-form-test.js +++ b/ui/tests/unit/components/auth-form-test.js @@ -16,24 +16,23 @@ module('Unit | Component | auth-form', function (hooks) { const component = this.owner.lookup('component:auth-form'); component.reopen({ methods: [], // eslint-disable-line + // performAuth is a callback passed from the parent component + // that is called in the return of the doSubmit method + // this component is not glimmerized and testing this functionality + // in an integration test requires additional role setup so + // stubbing here to test it is called with the correct args // eslint-disable-next-line - authenticate: { - unlinked() { - return { - perform(type, data) { - assert.deepEqual( - type, - 'token', - `Token type correctly passed to authenticate method for ${component.providerName}` - ); - assert.deepEqual( - data, - { token: component.token }, - `Token passed to authenticate method for ${component.providerName}` - ); - }, - }; - }, + performAuth(type, data) { + assert.deepEqual( + type, + 'token', + `Token type correctly passed to authenticate method for ${component.providerName}` + ); + assert.deepEqual( + data, + { token: component.token }, + `Token passed to authenticate method for ${component.providerName}` + ); }, });