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
~}}
\ 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? }}
\ 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}`
+ );
},
});