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
This commit is contained in:
claire bontempo 2024-06-20 12:40:28 -07:00 committed by GitHub
parent d4da61fc4e
commit 2482674312
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 716 additions and 579 deletions

View File

@ -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.
* <AuthForm @wrappedToken={{wrappedToken}} @cluster={{model}} @namespace={{namespaceQueryParam}} @selectedAuth={{authMethod}} @onSuccess={{action this.onSuccess}}/>```
* @example
* <AuthForm @cluster={{model}} @namespace="admin" @selectedAuth="token" @authIsRunning={{this.authenticate.isRunning}} @performAuth={{this.performAuth}} />
*
* @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);
},
},
});

View File

@ -0,0 +1,23 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{#if this.waitingForOktaNumberChallenge}}
<OktaNumberChallenge
@correctAnswer={{this.oktaNumberChallengeAnswer}}
@hasError={{this.authError}}
@onReturnToLogin={{this.onCancel}}
/>
{{else}}
<AuthForm
@wrappedToken={{@wrappedToken}}
@cluster={{@cluster}}
@namespace={{@namespace}}
@selectedAuth={{@selectedAuth}}
@error={{this.authError}}
@performAuth={{this.performAuth}}
@authIsRunning={{this.authenticate.isRunning}}
@delayIsIdle={{this.delayAuthMessageReminder.isIdle}}
/>
{{/if}}

View File

@ -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
* <Auth::Page @wrappedToken={{this.wrappedToken}} @cluster={{this.model}} @namespace={{this.namespaceQueryParam}} @selectedAuth={{this.authMethod}} @onSuccess={{action "onAuthResponse"}} />
*
* @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();
}
}

View File

@ -101,9 +101,5 @@ export default Controller.extend({
mfaErrors: null,
});
},
cancelAuthentication() {
this.set('cancelAuth', true);
this.set('waitingForOktaNumberChallenge', false);
},
},
});

View File

@ -3,202 +3,192 @@
SPDX-License-Identifier: BUSL-1.1
~}}
<div class="auth-form" data-test-auth-form>
{{#if (and this.waitingForOktaNumberChallenge (not this.cancelAuthForOktaNumberChallenge))}}
<OktaNumberChallenge
@correctAnswer={{this.oktaNumberChallengeAnswer}}
@hasError={{(not-eq this.error null)}}
@onReturnToLogin={{action "returnToLoginFromOktaNumberChallenge"}}
/>
{{else}}
{{#if this.hasMethodsWithPath}}
<nav class="tabs is-marginless">
<ul>
{{#each this.methodsToShow as |method|}}
{{#let (or method.path method.type) as |methodKey|}}
<li
class={{if
(and
this.selectedAuthIsPath (eq (or this.selectedAuthBackend.path this.selectedAuthBackend.type) methodKey)
)
"is-active"
""
}}
data-test-auth-method={{method.id}}
>
<LinkTo
@route="vault.cluster.auth"
@model={{this.cluster.name}}
@query={{hash with=methodKey}}
data-test-auth-method-link={{method.type}}
>
{{or method.id (capitalize method.type)}}
</LinkTo>
</li>
{{/let}}
{{/each}}
<li class={{unless this.selectedAuthIsPath "is-active" ""}} data-test-auth-method>
<LinkTo
@route="vault.cluster.auth"
@model={{this.cluster.name}}
@query={{hash with="token"}}
data-test-auth-method-link="other"
{{#if this.hasMethodsWithPath}}
<nav class="tabs is-marginless">
<ul>
{{#each this.methodsToShow as |method|}}
{{#let (or method.path method.type) as |methodKey|}}
<li
class={{if
(and this.selectedAuthIsPath (eq (or this.selectedAuthBackend.path this.selectedAuthBackend.type) methodKey))
"is-active"
""
}}
data-test-auth-method={{method.id}}
>
Other
</LinkTo>
</li>
</ul>
</nav>
{{/if}}
<div class="box is-marginless is-shadowless">
<MessageError @errorMessage={{if (and this.cluster.standby this.cspError) this.cspError this.error}} />
{{#if this.selectedAuthBackend.path}}
<div class="has-bottom-margin-s">
<p class="is-label">{{this.selectedAuthBackend.path}}</p>
<span class="description has-text-grey" data-test-description={{true}}>
{{this.selectedAuthBackend.mountDescription}}
</span>
</div>
{{/if}}
{{#if (or (not this.hasMethodsWithPath) (not this.selectedAuthIsPath))}}
<Select
@label="Method"
@name="auth-method"
@options={{this.authMethods}}
@valueAttribute={{"type"}}
@labelAttribute={{"typeDisplay"}}
@isFullwidth={{true}}
@selectedValue={{this.selectedAuth}}
@onChange={{action (mut this.selectedAuth)}}
/>
{{/if}}
{{#if (or (eq this.selectedAuthBackend.type "jwt") (eq this.selectedAuthBackend.type "oidc"))}}
<AuthJwt
@onError={{action "handleError"}}
@onLoading={{action (mut this.isLoading)}}
@namespace={{this.namespace}}
@onNamespace={{action (mut this.namespace)}}
@onSubmit={{action "doSubmit"}}
@onRoleName={{action (mut this.roleName)}}
@roleName={{this.roleName}}
@selectedAuthType={{this.selectedAuthBackend.type}}
@selectedAuthPath={{or this.customPath this.selectedAuthBackend.id}}
@disabled={{or this.authenticate.isRunning this.isLoading}}
>
<AuthFormOptions
@customPath={{this.customPath}}
@onPathChange={{action (mut this.customPath)}}
@selectedAuthIsPath={{this.selectedAuthIsPath}}
/>
</AuthJwt>
{{else if (eq this.selectedAuthBackend.type "saml")}}
<AuthSaml
@onError={{action "handleError"}}
@onLoading={{action (mut this.isLoading)}}
@namespace={{this.namespace}}
@onNamespace={{action (mut this.namespace)}}
@onSubmit={{action "doSubmit"}}
@onRoleName={{action (mut this.roleName)}}
@roleName={{this.roleName}}
@selectedAuthType={{this.selectedAuthBackend.type}}
@selectedAuthPath={{or this.customPath this.selectedAuthBackend.id}}
@disabled={{or this.authenticate.isRunning this.isLoading}}
>
<AuthFormOptions
@customPath={{this.customPath}}
@onPathChange={{action (mut this.customPath)}}
@selectedAuthIsPath={{this.selectedAuthIsPath}}
/>
</AuthSaml>
{{else}}
<form id="auth-form" onsubmit={{action "doSubmit" null}}>
{{#if (eq this.providerName "github")}}
<div class="field">
<label for="token" class="is-label">GitHub token</label>
<div class="control">
<Input
@type="password"
@value={{this.token}}
name="token"
id="token"
class="input"
data-test-token={{true}}
autocomplete="off"
spellcheck="false"
/>
</div>
</div>
{{else if (eq this.providerName "token")}}
<div class="field">
<label for="token" class="is-label">Token</label>
<div class="control">
<Input
@type="password"
@value={{this.token}}
name="token"
class="input"
autocomplete="off"
spellcheck="false"
data-test-token={{true}}
id="token"
/>
</div>
</div>
{{else}}
<div class="field">
<label for="username" class="is-label">Username</label>
<div class="control">
<Input
@value={{this.username}}
name="username"
id="username"
class="input"
autocomplete="off"
spellcheck="false"
data-test-username={{true}}
/>
</div>
</div>
<div class="field">
<label for="password" class="is-label">Password</label>
<div class="control">
<Input
@value={{this.password}}
name="password"
id="password"
@type="password"
class="input"
autocomplete="off"
spellcheck="false"
data-test-password={{true}}
/>
</div>
</div>
{{/if}}
{{#if (not-eq this.selectedAuthBackend.type "token")}}
<AuthFormOptions
@customPath={{this.customPath}}
@onPathChange={{action (mut this.customPath)}}
@selectedAuthIsPath={{this.selectedAuthIsPath}}
/>
{{/if}}
<Hds::Button
@text="Sign in"
@icon={{if this.authenticate.isRunning "loading"}}
data-test-auth-submit={{true}}
type="submit"
disabled={{this.authenticate.isRunning}}
id="auth-submit"
/>
{{#if (and this.delayAuthMessageReminder.isIdle this.showLoading)}}
<AlertInline
class="has-top-padding-s"
@type="info"
@message="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."
data-test-auth-message="push"
/>
{{/if}}
</form>
{{/if}}
</div>
<LinkTo
@route="vault.cluster.auth"
@model={{this.cluster.name}}
@query={{hash with=methodKey}}
data-test-auth-method-link={{method.type}}
>
{{or method.id (capitalize method.type)}}
</LinkTo>
</li>
{{/let}}
{{/each}}
<li class={{unless this.selectedAuthIsPath "is-active" ""}} data-test-auth-method="other">
<LinkTo
@route="vault.cluster.auth"
@model={{this.cluster.name}}
@query={{hash with="token"}}
data-test-auth-method-link="other"
>
Other
</LinkTo>
</li>
</ul>
</nav>
{{/if}}
<div class="box is-marginless is-shadowless">
<MessageError @errorMessage={{if (and this.cluster.standby this.cspError) this.cspError this.error}} />
{{#if this.selectedAuthBackend.path}}
<div class="has-bottom-margin-s">
<p class="is-label">{{this.selectedAuthBackend.path}}</p>
<span class="description has-text-grey" data-test-description={{true}}>
{{this.selectedAuthBackend.mountDescription}}
</span>
</div>
{{/if}}
{{#if (or (not this.hasMethodsWithPath) (not this.selectedAuthIsPath))}}
<Select
@label="Method"
@name="auth-method"
@options={{this.authMethods}}
@valueAttribute={{"type"}}
@labelAttribute={{"typeDisplay"}}
@isFullwidth={{true}}
@selectedValue={{this.selectedAuth}}
@onChange={{action (mut this.selectedAuth)}}
/>
{{/if}}
{{#if (or (eq this.selectedAuthBackend.type "jwt") (eq this.selectedAuthBackend.type "oidc"))}}
<AuthJwt
@onError={{action "handleError"}}
@onLoading={{action (mut this.isLoading)}}
@namespace={{this.namespace}}
@onNamespace={{action (mut this.namespace)}}
@onSubmit={{action "doSubmit"}}
@onRoleName={{action (mut this.roleName)}}
@roleName={{this.roleName}}
@selectedAuthType={{this.selectedAuthBackend.type}}
@selectedAuthPath={{or this.customPath this.selectedAuthBackend.id}}
@disabled={{or this.authIsRunning this.isLoading}}
>
<AuthFormOptions
@customPath={{this.customPath}}
@onPathChange={{action (mut this.customPath)}}
@selectedAuthIsPath={{this.selectedAuthIsPath}}
/>
</AuthJwt>
{{else if (eq this.selectedAuthBackend.type "saml")}}
<AuthSaml
@onError={{action "handleError"}}
@onLoading={{action (mut this.isLoading)}}
@namespace={{this.namespace}}
@onNamespace={{action (mut this.namespace)}}
@onSubmit={{action "doSubmit"}}
@onRoleName={{action (mut this.roleName)}}
@roleName={{this.roleName}}
@selectedAuthType={{this.selectedAuthBackend.type}}
@selectedAuthPath={{or this.customPath this.selectedAuthBackend.id}}
@disabled={{or this.authIsRunning this.isLoading}}
>
<AuthFormOptions
@customPath={{this.customPath}}
@onPathChange={{action (mut this.customPath)}}
@selectedAuthIsPath={{this.selectedAuthIsPath}}
/>
</AuthSaml>
{{else}}
<form id="auth-form" onsubmit={{action "doSubmit" null}}>
{{#if (eq this.providerName "github")}}
<div class="field">
<label for="token" class="is-label">GitHub token</label>
<div class="control">
<Input
@type="password"
@value={{this.token}}
name="token"
id="token"
class="input"
data-test-token={{true}}
autocomplete="off"
spellcheck="false"
/>
</div>
</div>
{{else if (eq this.providerName "token")}}
<div class="field">
<label for="token" class="is-label">Token</label>
<div class="control">
<Input
@type="password"
@value={{this.token}}
name="token"
class="input"
autocomplete="off"
spellcheck="false"
data-test-token={{true}}
id="token"
/>
</div>
</div>
{{else}}
<div class="field">
<label for="username" class="is-label">Username</label>
<div class="control">
<Input
@value={{this.username}}
name="username"
id="username"
class="input"
autocomplete="off"
spellcheck="false"
data-test-username
/>
</div>
</div>
<div class="field">
<label for="password" class="is-label">Password</label>
<div class="control">
<Input
@value={{this.password}}
name="password"
id="password"
@type="password"
class="input"
autocomplete="off"
spellcheck="false"
data-test-password
/>
</div>
</div>
{{/if}}
{{#if (not-eq this.selectedAuthBackend.type "token")}}
<AuthFormOptions
@customPath={{this.customPath}}
@onPathChange={{action (mut this.customPath)}}
@selectedAuthIsPath={{this.selectedAuthIsPath}}
/>
{{/if}}
<Hds::Button
@text="Sign in"
@icon={{if this.authIsRunning "loading"}}
data-test-auth-submit={{true}}
type="submit"
disabled={{this.authIsRunning}}
id="auth-submit"
/>
{{#if (and this.delayIsIdle this.showLoading)}}
<AlertInline
class="has-top-padding-s"
@type="info"
@message="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."
data-test-auth-message="push"
/>
{{/if}}
</form>
{{/if}}
</div>
</div>

View File

@ -2,42 +2,35 @@
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{! todo move to auth/ folder? }}
<div class="auth-form" data-test-okta-number-challenge>
<div class="box is-marginless is-shadowless">
<div class="field has-top-margin-xs">
<p data-test-okta-number-challenge-description>
To finish signing in, you will need to complete an additional MFA step.</p>
{{#if @hasError}}
<div class="has-top-margin-s">
<MessageError @errorMessage="There was a problem" />
<Hds::Button
@text="Return to login"
@color="secondary"
{{on "click" @onReturnToLogin}}
data-test-return-from-okta-number-challenge
/>
</div>
{{else if @correctAnswer}}
<div class="has-top-margin-s">
<p class="has-text-black has-text-weight-semibold" data-test-okta-number-challenge-verification-type>Okta
verification</p>
<p data-test-okta-number-challenge-verification-description>Select the following number to complete verification:</p>
<h1
class="title has-font-weight-normal has-top-margin-m has-bottom-margin-s"
data-test-okta-number-challenge-answer
>{{@correctAnswer}}</h1>
</div>
{{else}}
<div class="has-top-margin-l has-bottom-margin-m">
<div class="is-flex-row">
<FlightIcon @name="loading" />
<div class="has-left-margin-xs">
<p data-test-okta-number-challenge-loading>Please wait...</p>
</div>
</div>
</div>
{{/if}}
</div>
<p data-test-okta-number-challenge-description>
To finish signing in, you will need to complete an additional MFA step.
</p>
{{#if @hasError}}
<MessageError class="has-top-margin-s" @errorMessage={{@hasError}} />
{{else if @correctAnswer}}
<p class="has-top-margin-s has-text-black has-text-weight-semibold" data-test-verification-type>
Okta verification
</p>
<p data-test-description>Select the following number to complete verification:</p>
<h1 class="title has-font-weight-normal has-top-margin-m has-bottom-margin-s" data-test-answer>
{{@correctAnswer}}
</h1>
{{else}}
<div class="has-top-margin-l has-bottom-margin-m is-flex-row">
<FlightIcon @name="loading" />
<p class="has-left-margin-xs" data-test-loading>Please wait...</p>
</div>
{{/if}}
</div>
<Hds::Button
@text="Back to login"
@icon="arrow-left"
@color="tertiary"
class="has-bottom-margin-s has-left-margin-s"
{{on "click" @onReturnToLogin}}
data-test-back-button
/>
</div>

View File

@ -38,17 +38,9 @@
@color="tertiary"
{{on "click" (fn (mut this.mfaAuthData) null)}}
/>
{{else if this.waitingForOktaNumberChallenge}}
<Hds::Button
@text="Back to login"
@icon="arrow-left"
@isIconOnly={{true}}
@color="tertiary"
{{on "click" (action "cancelAuthentication")}}
/>
{{/if}}
<h1 class="title is-3">
{{if (or this.mfaAuthData this.waitingForOktaNumberChallenge) "Authenticate" "Sign in to Vault"}}
{{if this.mfaAuthData "Authenticate" "Sign in to Vault"}}
</h1>
</div>
{{/if}}
@ -101,17 +93,12 @@
@onError={{fn (mut this.mfaErrors)}}
/>
{{else}}
<AuthForm
<Auth::Page
@wrappedToken={{this.wrappedToken}}
@cluster={{this.model}}
@namespace={{this.namespaceQueryParam}}
@redirectTo={{this.redirectTo}}
@selectedAuth={{this.authMethod}}
@onSuccess={{action "onAuthResponse"}}
@setOktaNumberChallenge={{fn (mut this.waitingForOktaNumberChallenge)}}
@waitingForOktaNumberChallenge={{this.waitingForOktaNumberChallenge}}
@setCancellingAuth={{fn (mut this.cancelAuth)}}
@cancelAuthForOktaNumberChallenge={{this.cancelAuth}}
/>
{{/if}}
</:content>

View File

@ -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]',
};

View File

@ -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]',

View File

@ -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`
<AuthForm
@wrappedToken={{this.wrappedToken}}
@cluster={{this.cluster}}
@selectedAuth={{this.selectedAuth}}
@performAuth={{this.performAuth}}
@authIsRunning={{this.authIsRunning}}
@delayIsIdle={{this.delayIsIdle}}
/>`);
};
});
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`<AuthForm @cluster={{this.cluster}} @selectedAuth={{this.selectedAuth}} />`);
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`<AuthForm @cluster={{this.cluster}} @selectedAuth={{this.selectedAuth}} />`);
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`<AuthForm @cluster={{this.cluster}} @selectedAuth={{this.selectedAuth}} />`);
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`<AuthForm @cluster={{this.cluster}} />`);
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`<AuthForm @cluster={{this.cluster}} />`);
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');
await this.renderComponent();
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 renders the description', async function (assert) {
const methods = {
'approle/': {
type: 'userpass',
description: 'app description',
},
};
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({}));
await render(hbs`<AuthForm @cluster={{this.cluster}} />`);
assert.strictEqual(
component.descriptionText,
'app description',
'renders a description for auth methods'
);
});
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',
},
};
this.server.get('/sys/internal/ui/mounts', () => {
return { data: { auth: methods } };
});
this.set('cluster', EmberObject.create({}));
this.set('selectedAuth', 'foo/');
await render(hbs`<AuthForm @cluster={{this.cluster}} @selectedAuth={{this.selectedAuth}} />`);
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`<AuthForm @cluster={{this.cluster}} />`);
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`<AuthForm @cluster={{this.cluster}} @wrappedToken={{this.wrappedToken}} @onSuccess={{this.onSuccess}} />`
);
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`<AuthForm @cluster={{this.cluster}} @wrappedToken={{this.wrappedToken}} />`);
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`<AuthForm @cluster={{this.cluster}} />`);
await this.renderComponent();
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`<AuthForm @cluster={{this.cluster}} />`);
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);
});
});

View File

@ -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`
<Auth::Page
@wrappedToken={{this.wrappedToken}}
@cluster={{this.cluster}}
@namespace={{this.namespaceQueryParam}}
@selectedAuth={{this.authMethod}}
@onSuccess={{this.onSuccess}}
/>
`);
};
});
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');
});
});

View File

@ -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`
<OktaNumberChallenge
@correctAnswer={{this.oktaNumberChallengeAnswer}}
@hasError={{this.hasError}}
@onReturnToLogin={{this.onCancel}}
/>
`);
};
});
test('it should render correct descriptions', async function (assert) {
await render(hbs`<OktaNumberChallenge @correctAnswer={{this.oktaNumberChallengeAnswer}}/>`);
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`<OktaNumberChallenge @correctAnswer={{this.oktaNumberChallengeAnswer}}/>`);
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`<OktaNumberChallenge @correctAnswer={{this.oktaNumberChallengeAnswer}} @hasError={{this.hasError}} @onReturnToLogin={{fn (mut this.returnToLogin) true}}/>`
);
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');
});
});

View File

@ -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}`
);
},
});