mirror of
https://github.com/hashicorp/vault.git
synced 2025-11-28 06:01:08 +01:00
UI: Build Auth::FormTemplate (#30257)
* move auth tests to folder * polish auth tests * build auth::form-template component * add components for other supported methods * add comments, add tests * convert to typesript * conver base.js to typescript * use getRelativePath helper * fix logic for hiding advanced settings toggle, use getter for selecting tab index * update tests * how in the heck did that happen * add punctuation to comments, clarify var name * update loginFields to array of objects * update tests * add helper text and custom label tests * woops, test was in the beforeEach block
This commit is contained in:
parent
08c5a52b02
commit
c4cfa371c1
22
ui/app/components/auth/fields.hbs
Normal file
22
ui/app/components/auth/fields.hbs
Normal file
@ -0,0 +1,22 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
{{#each @loginFields as |field|}}
|
||||
{{#let field.name field.label field.helperText as |name label helperText|}}
|
||||
<Hds::Form::TextInput::Field
|
||||
autocomplete={{this.setAutocomplete name}}
|
||||
@type={{this.setInputType name}}
|
||||
name={{name}}
|
||||
class="has-bottom-margin-m"
|
||||
data-test-input={{name}}
|
||||
as |F|
|
||||
>
|
||||
<F.Label>{{or label (capitalize name)}}</F.Label>
|
||||
{{#if helperText}}
|
||||
<F.HelperText>{{helperText}}</F.HelperText>
|
||||
{{/if}}
|
||||
</Hds::Form::TextInput::Field>
|
||||
{{/let}}
|
||||
{{/each}}
|
||||
34
ui/app/components/auth/fields.ts
Normal file
34
ui/app/components/auth/fields.ts
Normal file
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
// TODO pending feedback from the security team, we may keep autocomplete="off" for login fields
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
interface Args {
|
||||
loginFields: Field[];
|
||||
}
|
||||
|
||||
interface Field {
|
||||
name: string; // sets input name
|
||||
label?: string; // label will be "name" capitalized unless label exists
|
||||
helperText?: string;
|
||||
}
|
||||
|
||||
export default class AuthFields extends Component<Args> {
|
||||
// token or password should render as "password" types, otherwise render text inputs
|
||||
setInputType = (field: string) => (['token', 'password'].includes(field) ? 'password' : 'text');
|
||||
|
||||
setAutocomplete = (fieldName: string) => {
|
||||
switch (fieldName) {
|
||||
case 'password':
|
||||
return 'current-password';
|
||||
case 'token':
|
||||
return 'off';
|
||||
default:
|
||||
return fieldName;
|
||||
}
|
||||
};
|
||||
}
|
||||
107
ui/app/components/auth/form-template.hbs
Normal file
107
ui/app/components/auth/form-template.hbs
Normal file
@ -0,0 +1,107 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
{{#if this.formComponent}}
|
||||
{{#let (component this.formComponent) as |AuthFormComponent|}}
|
||||
{{! renders Auth::Form::Base or Auth::Form::<Type>}}
|
||||
<AuthFormComponent
|
||||
@authType={{this.selectedAuthMethod}}
|
||||
@cluster={{@cluster}}
|
||||
@onError={{this.handleError}}
|
||||
@onSuccess={{@onSuccess}}
|
||||
@wrappedToken={{@wrappedToken}}
|
||||
>
|
||||
<:namespace>
|
||||
{{#if this.version.isEnterprise}}
|
||||
<Auth::NamespaceInput
|
||||
@disabled={{@oidcProviderQueryParam}}
|
||||
@hvdManagedNamespace={{this.flags.hvdManagedNamespaceRoot}}
|
||||
@namespace={{this.namespaceInput}}
|
||||
@updateNamespace={{this.handleNamespaceUpdate}}
|
||||
/>
|
||||
{{/if}}
|
||||
</:namespace>
|
||||
|
||||
<:back>
|
||||
{{#if this.showOtherMethods}}
|
||||
<Hds::Button
|
||||
@text="Back"
|
||||
{{on "click" this.toggleView}}
|
||||
@color="tertiary"
|
||||
@icon="arrow-left"
|
||||
data-test-back-button
|
||||
/>
|
||||
{{/if}}
|
||||
</:back>
|
||||
|
||||
{{! TABS OR DROPDOWN }}
|
||||
<:authSelectOptions>
|
||||
<div class="has-bottom-margin-m">
|
||||
{{#if this.renderTabs}}
|
||||
<Auth::Tabs
|
||||
@authTabs={{this.authTabs}}
|
||||
@displayNameHelper={{this.displayName}}
|
||||
@onTabClick={{fn this.handleAuthSelect "tab"}}
|
||||
@selectedAuthMethod={{this.selectedAuthMethod}}
|
||||
@selectedTabIndex={{this.selectedTabIndex}}
|
||||
/>
|
||||
{{else}}
|
||||
<Hds::Form::Select::Field
|
||||
name="selectedAuthMethod"
|
||||
{{on "input" (fn this.handleAuthSelect "dropdown")}}
|
||||
data-test-select="auth type"
|
||||
as |F|
|
||||
>
|
||||
<F.Label class="has-top-margin-m">Auth method</F.Label>
|
||||
<F.Options>
|
||||
{{#each this.availableMethodTypes as |type|}}
|
||||
<option selected={{eq this.selectedAuthMethod type}} value={{type}}>
|
||||
{{this.displayName type}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</F.Options>
|
||||
</Hds::Form::Select::Field>
|
||||
{{/if}}
|
||||
</div>
|
||||
</:authSelectOptions>
|
||||
|
||||
<:error>
|
||||
{{#if this.errorMessage}}
|
||||
<MessageError @errorMessage={{this.errorMessage}} />
|
||||
{{/if}}
|
||||
</:error>
|
||||
|
||||
<:advancedSettings>
|
||||
{{! tabs render their own mount path inputs and token does not support custom paths }}
|
||||
{{#if (and (not this.renderTabs) (not-eq this.selectedAuthMethod "token"))}}
|
||||
<Hds::Reveal @text="Advanced settings" data-test-auth-form-options-toggle class="is-fullwidth">
|
||||
<Hds::Form::TextInput::Field name="path" data-test-input="path" as |F|>
|
||||
<F.Label class="has-top-margin-m">Mount path</F.Label>
|
||||
<F.HelperText>
|
||||
If this authentication method was mounted using a non-default path, input it below. Otherwise Vault will
|
||||
assume the default path
|
||||
<Hds::Text::Code class="code-in-text">{{this.selectedAuthMethod}}</Hds::Text::Code>
|
||||
.</F.HelperText>
|
||||
</Hds::Form::TextInput::Field>
|
||||
</Hds::Reveal>
|
||||
{{/if}}
|
||||
</:advancedSettings>
|
||||
|
||||
<:footer>
|
||||
{{#if this.renderTabs}}
|
||||
<Hds::Button
|
||||
{{on "click" this.toggleView}}
|
||||
@color="tertiary"
|
||||
@icon="arrow-right"
|
||||
@iconPosition="trailing"
|
||||
@isInline={{true}}
|
||||
@text="Sign in with other methods"
|
||||
data-test-other-methods-button
|
||||
/>
|
||||
{{/if}}
|
||||
</:footer>
|
||||
</AuthFormComponent>
|
||||
{{/let}}
|
||||
{{/if}}
|
||||
213
ui/app/components/auth/form-template.ts
Normal file
213
ui/app/components/auth/form-template.ts
Normal file
@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { restartableTask, timeout } from 'ember-concurrency';
|
||||
import { waitFor } from '@ember/test-waiters';
|
||||
import { ALL_LOGIN_METHODS, supportedTypes } from 'vault/utils/supported-login-methods';
|
||||
import { getRelativePath } from 'core/utils/sanitize-path';
|
||||
|
||||
import type FlagsService from 'vault/services/flags';
|
||||
import type Store from '@ember-data/store';
|
||||
import type VersionService from 'vault/services/version';
|
||||
import type ClusterModel from 'vault/models/cluster';
|
||||
import type { HTMLElementEvent } from 'vault/forms';
|
||||
|
||||
/**
|
||||
* @module Auth::FormTemplate
|
||||
* This component is responsible for managing the layout and display logic for the auth form. When initialized it fetches
|
||||
* the unauthenticated sys/internal/ui/mounts endpoint to check the listing_visibility configuration of available mounts.
|
||||
* If mounts have been configured as listing_visibility="unauth" then tabs render for the corresponding method types,
|
||||
* otherwise all auth methods display in a dropdown list. The endpoint is re-requested anytime the namespace input is updated.
|
||||
*
|
||||
* When auth type changes (by selecting a new one from the dropdown or select a tab), the form component updates and
|
||||
* dynamically renders the corresponding form.
|
||||
*
|
||||
*
|
||||
* @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 {function} handleNamespaceUpdate - callback task that passes user input to the controller and updates the namespace query param in the url
|
||||
* @param {string} namespace - namespace query param from the url
|
||||
* @param {function} onSuccess - callback after the initial authentication request, if an mfa_requirement exists the parent renders the mfa form otherwise it fires the authSuccess action in the auth controller and handles transitioning to the app
|
||||
*
|
||||
* */
|
||||
|
||||
interface Args {
|
||||
wrappedToken: string;
|
||||
cluster: ClusterModel;
|
||||
handleNamespaceUpdate: CallableFunction;
|
||||
namespace: string;
|
||||
onSuccess: CallableFunction;
|
||||
}
|
||||
|
||||
interface AuthTabs {
|
||||
// key is the auth method type
|
||||
[key: string]: MountData[];
|
||||
}
|
||||
|
||||
interface MountData {
|
||||
path: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
config?: object | null;
|
||||
}
|
||||
|
||||
export default class AuthFormTemplate extends Component<Args> {
|
||||
@service declare readonly flags: FlagsService;
|
||||
@service declare readonly store: Store;
|
||||
@service declare readonly version: VersionService;
|
||||
|
||||
// form display logic
|
||||
@tracked authTabs: AuthTabs | null = null;
|
||||
@tracked showOtherMethods = false;
|
||||
|
||||
// auth login variables
|
||||
@tracked selectedAuthMethod = 'token';
|
||||
@tracked errorMessage = '';
|
||||
|
||||
displayName = (type: string) => {
|
||||
const displayName = ALL_LOGIN_METHODS?.find((t) => t.type === type)?.displayName;
|
||||
return displayName || type;
|
||||
};
|
||||
|
||||
constructor(owner: unknown, args: Args) {
|
||||
super(owner, args);
|
||||
this.fetchMounts.perform();
|
||||
}
|
||||
|
||||
get availableMethodTypes() {
|
||||
return supportedTypes(this.version.isEnterprise);
|
||||
}
|
||||
|
||||
get formComponent() {
|
||||
const { selectedAuthMethod } = this;
|
||||
// isSupported means there is a component file defined for that auth type
|
||||
const isSupported = this.availableMethodTypes.includes(selectedAuthMethod);
|
||||
const formFile = () => (['oidc', 'jwt'].includes(selectedAuthMethod) ? 'oidc-jwt' : selectedAuthMethod);
|
||||
const component = isSupported ? formFile() : 'base';
|
||||
|
||||
// an Auth::Form::<Type> component exists for each method in supported-login-methods
|
||||
return `auth/form/${component}`;
|
||||
}
|
||||
|
||||
get namespaceInput() {
|
||||
const namespaceQueryParam = this.args.namespace;
|
||||
if (this.flags.hvdManagedNamespaceRoot) {
|
||||
// When managed, the user isn't allowed to edit the prefix `admin/`
|
||||
// so prefill just the relative path in the namespace input
|
||||
const path = getRelativePath(namespaceQueryParam, this.flags.hvdManagedNamespaceRoot);
|
||||
return path ? `/${path}` : '';
|
||||
}
|
||||
return namespaceQueryParam;
|
||||
}
|
||||
|
||||
get renderTabs() {
|
||||
// renders tabs if listing visibility is set (auth tabs exist)
|
||||
// and user has NOT clicked "Sign in with other"
|
||||
if (this.authTabs && !this.showOtherMethods) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
get selectedTabIndex() {
|
||||
if (this.authTabs) {
|
||||
return Object.keys(this.authTabs).indexOf(this.selectedAuthMethod);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
setAuthTypeFromTab(idx: number) {
|
||||
const authTypes = this.authTabs ? Object.keys(this.authTabs) : [];
|
||||
this.selectedAuthMethod = authTypes[idx] || '';
|
||||
}
|
||||
|
||||
@action
|
||||
handleAuthSelect(element: string, event: HTMLElementEvent<HTMLInputElement> | null, idx: number) {
|
||||
if (element === 'tab') {
|
||||
this.setAuthTypeFromTab(idx);
|
||||
} else if (event?.target?.value) {
|
||||
this.selectedAuthMethod = event.target.value;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
toggleView() {
|
||||
this.showOtherMethods = !this.showOtherMethods;
|
||||
|
||||
if (this.renderTabs) {
|
||||
// reset selected auth method to first tab
|
||||
this.handleAuthSelect('tab', null, 0);
|
||||
} else {
|
||||
// all methods render, reset dropdown
|
||||
this.selectedAuthMethod = 'token';
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleError(message: string) {
|
||||
this.errorMessage = message;
|
||||
}
|
||||
|
||||
@action
|
||||
handleNamespaceUpdate(event: HTMLElementEvent<HTMLInputElement>) {
|
||||
// update query param
|
||||
this.args.handleNamespaceUpdate(event.target.value);
|
||||
// reset tabs
|
||||
this.authTabs = null;
|
||||
// fetch mounts for that namespace
|
||||
this.fetchMounts.perform(500);
|
||||
}
|
||||
|
||||
fetchMounts = restartableTask(
|
||||
waitFor(async (wait = 0) => {
|
||||
// task is `restartable` so if the user starts typing again,
|
||||
// it will cancel and restart from the beginning.
|
||||
if (wait) await timeout(wait);
|
||||
|
||||
try {
|
||||
// clear ember data store before re-requesting.. :(
|
||||
this.store.unloadAll('auth-method');
|
||||
|
||||
// unauthMounts are tuned with listing_visibility="unauth"
|
||||
const unauthMounts = await this.store.findAll('auth-method', {
|
||||
adapterOptions: {
|
||||
unauthenticated: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (unauthMounts.length !== 0) {
|
||||
this.authTabs = unauthMounts.reduce((obj: AuthTabs, m) => {
|
||||
// serialize the ember data model into a regular ol' object
|
||||
const mountData = m.serialize();
|
||||
const methodType = mountData.type;
|
||||
if (!Object.keys(obj).includes(methodType)) {
|
||||
// create a new empty array for that type
|
||||
obj[methodType] = [];
|
||||
}
|
||||
|
||||
if (Array.isArray(obj[methodType])) {
|
||||
// push mount data into corresponding type's array
|
||||
obj[methodType].push(mountData);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
// set tracked selected auth type to first tab
|
||||
this.setAuthTypeFromTab(0);
|
||||
// hide other methods to prioritize tabs (visible mounts)
|
||||
this.showOtherMethods = false;
|
||||
}
|
||||
} catch (e) {
|
||||
// if for some reason there's an error fetching mounts, swallow and just show standard form
|
||||
this.authTabs = null;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
29
ui/app/components/auth/form/base.hbs
Normal file
29
ui/app/components/auth/form/base.hbs
Normal file
@ -0,0 +1,29 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
<form {{on "submit" this.onSubmit}} data-test-auth-form={{@authType}}>
|
||||
|
||||
{{yield to="namespace"}}
|
||||
|
||||
<div class="has-padding-l">
|
||||
{{yield to="back"}}
|
||||
{{yield to="authSelectOptions"}}
|
||||
{{yield to="error"}}
|
||||
|
||||
<Auth::Fields @loginFields={{this.loginFields}} />
|
||||
|
||||
{{yield to="advancedSettings"}}
|
||||
|
||||
<Hds::Button
|
||||
@text="Sign in"
|
||||
@isFullWidth={{true}}
|
||||
type="submit"
|
||||
class="has-top-margin-m has-bottom-margin-m"
|
||||
data-test-auth-submit
|
||||
/>
|
||||
|
||||
{{yield to="footer"}}
|
||||
</div>
|
||||
</form>
|
||||
79
ui/app/components/auth/form/base.ts
Normal file
79
ui/app/components/auth/form/base.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { service } from '@ember/service';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { waitFor } from '@ember/test-waiters';
|
||||
|
||||
import type AuthService from 'vault/vault/services/auth';
|
||||
import type ClusterModel from 'vault/models/cluster';
|
||||
import type { HTMLElementEvent } from 'vault/forms';
|
||||
|
||||
/**
|
||||
* @module Auth::Base
|
||||
*
|
||||
* @param {string} authType - chosen login method type
|
||||
* @param {object} cluster - The cluster model which contains information such as cluster id, name and boolean for if the cluster is in standby
|
||||
* @param {function} onError - callback if there is a login error
|
||||
* @param {function} onSuccess - calls onAuthResponse in auth/page redirects if successful
|
||||
*/
|
||||
|
||||
interface Args {
|
||||
authType: string;
|
||||
cluster: ClusterModel;
|
||||
onError: CallableFunction;
|
||||
onSuccess: CallableFunction;
|
||||
}
|
||||
|
||||
export default class AuthBase extends Component<Args> {
|
||||
@service declare readonly auth: AuthService;
|
||||
|
||||
@action
|
||||
onSubmit(event: HTMLElementEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.target as HTMLFormElement);
|
||||
const data: Record<string, FormDataEntryValue | null> = {};
|
||||
|
||||
for (const key of formData.keys()) {
|
||||
data[key] = formData.get(key);
|
||||
}
|
||||
|
||||
this.login.unlinked().perform(data);
|
||||
}
|
||||
|
||||
login = task(
|
||||
waitFor(async (data) => {
|
||||
try {
|
||||
const authResponse = await this.auth.authenticate({
|
||||
clusterId: this.args.cluster.id,
|
||||
backend: this.args.authType,
|
||||
data,
|
||||
selectedAuth: this.args.authType,
|
||||
});
|
||||
|
||||
// responsible for redirect after auth data is persisted
|
||||
this.onSuccess(authResponse);
|
||||
} catch (error) {
|
||||
this.onError(error as Error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// if we move auth service authSuccess method here (or to each auth method component)
|
||||
// then call that before calling parent this.args.onSuccess
|
||||
onSuccess(authResponse: object) {
|
||||
// responsible for redirect after auth data is persisted
|
||||
this.args.onSuccess(authResponse, this.args.authType);
|
||||
}
|
||||
|
||||
onError(error: Error) {
|
||||
if (!this.auth.mfaErrors) {
|
||||
const errorMessage = `Authentication failed: ${this.auth.handleError(error)}`;
|
||||
this.args.onError(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
15
ui/app/components/auth/form/github.js
Normal file
15
ui/app/components/auth/form/github.js
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import AuthBase from './base';
|
||||
|
||||
/**
|
||||
* @module Auth::Form::Github
|
||||
* see Auth::Base
|
||||
*/
|
||||
|
||||
export default class AuthFormGithub extends AuthBase {
|
||||
loginFields = [{ name: 'token', label: 'Github token' }];
|
||||
}
|
||||
15
ui/app/components/auth/form/ldap.js
Normal file
15
ui/app/components/auth/form/ldap.js
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import AuthBase from './base';
|
||||
|
||||
/**
|
||||
* @module Auth::Form::Ldap
|
||||
* see Auth::Base
|
||||
*/
|
||||
|
||||
export default class AuthFormLdap extends AuthBase {
|
||||
loginFields = [{ name: 'username' }, { name: 'password' }];
|
||||
}
|
||||
24
ui/app/components/auth/form/oidc-jwt.js
Normal file
24
ui/app/components/auth/form/oidc-jwt.js
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import AuthBase from './base';
|
||||
|
||||
/**
|
||||
* @module Auth::Form::OidcJwt
|
||||
* see Auth::Base
|
||||
*
|
||||
* OIDC can be configured at 'jwt' or 'oidc', see https://developer.hashicorp.com/vault/docs/auth/jwt
|
||||
* we use the same template because displaying the JWT token input depends on the error message returned when fetching
|
||||
* the role
|
||||
*/
|
||||
|
||||
export default class AuthFormOidcJwt extends AuthBase {
|
||||
loginFields = [
|
||||
{
|
||||
name: 'role',
|
||||
helperText: 'Vault will use the default role to sign in if this field is left blank.',
|
||||
},
|
||||
];
|
||||
}
|
||||
15
ui/app/components/auth/form/okta.js
Normal file
15
ui/app/components/auth/form/okta.js
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import AuthBase from './base';
|
||||
|
||||
/**
|
||||
* @module Auth::Form::Okta
|
||||
* see Auth::Base
|
||||
* */
|
||||
|
||||
export default class AuthFormOkta extends AuthBase {
|
||||
loginFields = [{ name: 'username' }, { name: 'password' }];
|
||||
}
|
||||
15
ui/app/components/auth/form/radius.js
Normal file
15
ui/app/components/auth/form/radius.js
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import AuthBase from './base';
|
||||
|
||||
/**
|
||||
* @module Auth::Form::Radius
|
||||
* see Auth::Base
|
||||
*/
|
||||
|
||||
export default class AuthFormRadius extends AuthBase {
|
||||
loginFields = [{ name: 'username' }, { name: 'password' }];
|
||||
}
|
||||
20
ui/app/components/auth/form/saml.js
Normal file
20
ui/app/components/auth/form/saml.js
Normal file
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import AuthBase from './base';
|
||||
|
||||
/**
|
||||
* @module Auth::Form::Saml
|
||||
* see Auth::Base
|
||||
*/
|
||||
|
||||
export default class AuthFormSaml extends AuthBase {
|
||||
loginFields = [
|
||||
{
|
||||
name: 'role',
|
||||
helperText: 'Vault will use the default role to sign in if this field is left blank.',
|
||||
},
|
||||
];
|
||||
}
|
||||
15
ui/app/components/auth/form/token.js
Normal file
15
ui/app/components/auth/form/token.js
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import AuthBase from './base';
|
||||
|
||||
/**
|
||||
* @module Auth::Form::Token
|
||||
* see Auth::Base
|
||||
* */
|
||||
|
||||
export default class AuthFormToken extends AuthBase {
|
||||
loginFields = [{ name: 'token' }];
|
||||
}
|
||||
15
ui/app/components/auth/form/userpass.js
Normal file
15
ui/app/components/auth/form/userpass.js
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import AuthBase from './base';
|
||||
|
||||
/**
|
||||
* @module Auth::Form::Userpass
|
||||
*
|
||||
* */
|
||||
|
||||
export default class AuthFormUserpass extends AuthBase {
|
||||
loginFields = [{ name: 'username' }, { name: 'password' }];
|
||||
}
|
||||
47
ui/app/components/auth/namespace-input.hbs
Normal file
47
ui/app/components/auth/namespace-input.hbs
Normal file
@ -0,0 +1,47 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
<div class="background-neutral-50 has-padding-l">
|
||||
{{#if @hvdManagedNamespace}}
|
||||
<Hds::Form::Field @layout="vertical" disabled={{@disabled}} as |F|>
|
||||
<F.Label>Namespace</F.Label>
|
||||
<F.Control>
|
||||
<Hds::SegmentedGroup class="is-fullwidth" as |SG|>
|
||||
<SG.TextInput
|
||||
@type="text"
|
||||
@value="/{{@hvdManagedNamespace}}"
|
||||
aria-label="Root namespace for H-C-P managed cluster"
|
||||
class="one-fourth-width"
|
||||
name="hvd-root-namespace"
|
||||
readonly
|
||||
data-test-managed-namespace-root
|
||||
/>
|
||||
<SG.TextInput
|
||||
{{on "input" @updateNamespace}}
|
||||
@value={{@namespace}}
|
||||
autocomplete="namespace"
|
||||
disabled={{@disabled}}
|
||||
name="namespace"
|
||||
placeholder="/ (default)"
|
||||
data-test-input="namespace"
|
||||
/>
|
||||
</Hds::SegmentedGroup>
|
||||
</F.Control>
|
||||
</Hds::Form::Field>
|
||||
{{else}}
|
||||
<Hds::Form::TextInput::Field
|
||||
{{on "input" @updateNamespace}}
|
||||
@value={{@namespace}}
|
||||
autocomplete="namespace"
|
||||
disabled={{@disabled}}
|
||||
name="namespace"
|
||||
placeholder="/ (root)"
|
||||
data-test-input="namespace"
|
||||
as |F|
|
||||
>
|
||||
<F.Label>Namespace</F.Label>
|
||||
</Hds::Form::TextInput::Field>
|
||||
{{/if}}
|
||||
</div>
|
||||
45
ui/app/components/auth/tabs.hbs
Normal file
45
ui/app/components/auth/tabs.hbs
Normal file
@ -0,0 +1,45 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
<Hds::Tabs @onClickTab={{@onTabClick}} @selectedTabIndex={{@selectedTabIndex}} as |T|>
|
||||
{{#each-in @authTabs as |methodType mounts|}}
|
||||
<T.Tab data-test-auth-method={{methodType}}>{{@displayNameHelper methodType}}</T.Tab>
|
||||
<T.Panel>
|
||||
<div class="has-top-padding-m">
|
||||
{{! Elements "behind" tabs always render on the DOM and are just superficially hidden/shown.
|
||||
However, for accessibility, we only want to render form inputs relevant to the selected method.
|
||||
By wrapping the elements in this conditional, it only renders them when the tab is selected. }}
|
||||
{{#if (eq @selectedAuthMethod methodType)}}
|
||||
{{#if (gt mounts.length 1)}}
|
||||
{{! render dropdown of mount paths }}
|
||||
<Hds::Form::Select::Field name="path" data-test-select="path" as |F|>
|
||||
<F.Label>Mount path</F.Label>
|
||||
<F.Options>
|
||||
{{#each mounts as |mount|}}
|
||||
<option value={{mount.path}}>{{mount.path}}</option>
|
||||
{{/each}}
|
||||
</F.Options>
|
||||
</Hds::Form::Select::Field>
|
||||
{{else}}
|
||||
{{#let (get mounts "0") as |mount|}}
|
||||
{{#if (and (eq @selectedAuthMethod "token") mount.description)}}
|
||||
{{! the token auth method does't support a custom path }}
|
||||
<Hds::Text::Body @tag="p" @color="faint">{{mount.description}} data-test-description</Hds::Text::Body>
|
||||
{{else}}
|
||||
{{! if it's the only available mount path render a readonly input }}
|
||||
<Hds::Form::TextInput::Field readonly name="path" @value={{mount.path}} data-test-input="path" as |F|>
|
||||
<F.Label>Mount path</F.Label>
|
||||
{{#if mount.description}}
|
||||
<F.HelperText data-test-description>{{mount.description}}</F.HelperText>
|
||||
{{/if}}
|
||||
</Hds::Form::TextInput::Field>
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</T.Panel>
|
||||
{{/each-in}}
|
||||
</Hds::Tabs>
|
||||
@ -8,6 +8,10 @@
|
||||
/* This helper includes styles referencing background color, border color, and text color. */
|
||||
|
||||
// background colors
|
||||
.background-neutral-50 {
|
||||
background: color_variables.$neutral-50;
|
||||
}
|
||||
|
||||
.has-background-white-bis {
|
||||
background: color_variables.$ui-gray-050;
|
||||
}
|
||||
|
||||
@ -3,6 +3,16 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
// HDS TOKENS
|
||||
|
||||
// Grey
|
||||
$neutral-50: var(--token-color-palette-neutral-50);
|
||||
|
||||
/*
|
||||
DEPRECATED
|
||||
below variables are deprecated, use HDS tokens instead
|
||||
*/
|
||||
|
||||
// UI Gray
|
||||
$ui-gray-010: #fbfbfc;
|
||||
$ui-gray-050: #f7f8fa;
|
||||
|
||||
58
ui/app/utils/supported-login-methods.ts
Normal file
58
ui/app/utils/supported-login-methods.ts
Normal file
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
/**
|
||||
* The web UI only supports logging in with these auth methods.
|
||||
* The method data is all related to logic for authenticating via that method.
|
||||
* This is a subset of the methods found in the `mountable-auth-methods` util,
|
||||
* which lists all the methods that can be enabled and mounted.
|
||||
*/
|
||||
|
||||
export const BASE_LOGIN_METHODS = [
|
||||
{
|
||||
type: 'token',
|
||||
displayName: 'Token',
|
||||
},
|
||||
{
|
||||
type: 'userpass',
|
||||
displayName: 'Username',
|
||||
},
|
||||
{
|
||||
type: 'ldap',
|
||||
displayName: 'LDAP',
|
||||
},
|
||||
{
|
||||
type: 'okta',
|
||||
displayName: 'Okta',
|
||||
},
|
||||
{
|
||||
type: 'jwt',
|
||||
displayName: 'JWT',
|
||||
},
|
||||
{
|
||||
type: 'oidc',
|
||||
displayName: 'OIDC',
|
||||
},
|
||||
{
|
||||
type: 'radius',
|
||||
displayName: 'RADIUS',
|
||||
},
|
||||
{
|
||||
type: 'github',
|
||||
displayName: 'GitHub',
|
||||
},
|
||||
];
|
||||
|
||||
export const ENTERPRISE_LOGIN_METHODS = [
|
||||
{
|
||||
type: 'saml',
|
||||
displayName: 'SAML',
|
||||
},
|
||||
];
|
||||
|
||||
export const ALL_LOGIN_METHODS = [...BASE_LOGIN_METHODS, ...ENTERPRISE_LOGIN_METHODS];
|
||||
|
||||
export const supportedTypes = (isEnterprise: boolean) =>
|
||||
isEnterprise ? ALL_LOGIN_METHODS.map((m) => m.type) : BASE_LOGIN_METHODS.map((m) => m.type);
|
||||
@ -5,7 +5,7 @@
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupApplicationTest } from 'ember-qunit';
|
||||
import { click, currentURL, visit, waitUntil, find, fillIn } from '@ember/test-helpers';
|
||||
import { click, currentURL, visit, waitUntil, find, fillIn, typeIn } from '@ember/test-helpers';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import { allSupportedAuthBackends, supportedAuthBackends } from 'vault/helpers/supported-auth-backends';
|
||||
import VAULT_KEYS from 'vault/tests/helpers/vault-keys';
|
||||
@ -16,7 +16,7 @@ import {
|
||||
mountEngineCmd,
|
||||
runCmd,
|
||||
} from 'vault/tests/helpers/commands';
|
||||
import { login, loginMethod, loginNs, logout } from 'vault/tests/helpers/auth/auth-helpers';
|
||||
import { login, loginMethod, loginNs } from 'vault/tests/helpers/auth/auth-helpers';
|
||||
import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
@ -212,62 +212,44 @@ module('Acceptance | auth', function (hooks) {
|
||||
assert.strictEqual(currentURL(), '/vault/dashboard');
|
||||
});
|
||||
|
||||
module('Enterprise', function (hooks) {
|
||||
hooks.beforeEach(async function () {
|
||||
module('Enterprise', function () {
|
||||
// this test is specifically to cover a token renewal bug within namespaces
|
||||
// namespace_path isn't returned by the renew-self response and so the auth service was
|
||||
// incorrectly setting userRootNamespace to '' (which denotes 'root'). this caused
|
||||
// subsequent capability checks fail because they would not be queried with the appropriate namespace header
|
||||
// if this test fails because a POST /v1/sys/capabilities-self returns a 403, then we have a problem!
|
||||
test('it sets namespace when renewing token', async function (assert) {
|
||||
const uid = uuidv4();
|
||||
this.ns = `admin-${uid}`;
|
||||
const ns = `admin-${uid}`;
|
||||
// log in to root to create namespace
|
||||
await login();
|
||||
await runCmd(createNS(this.ns), false);
|
||||
await runCmd(createNS(ns), false);
|
||||
// login to namespace, mount userpass, create policy and user
|
||||
await loginNs(this.ns);
|
||||
this.db = `database-${uid}`;
|
||||
this.userpass = `userpass-${uid}`;
|
||||
this.user = 'bob';
|
||||
this.policyName = `policy-${this.userpass}`;
|
||||
this.policy = `
|
||||
path "${this.db}/" {
|
||||
await loginNs(ns);
|
||||
const db = `database-${uid}`;
|
||||
const userpass = `userpass-${uid}`;
|
||||
const user = 'bob';
|
||||
const policyName = `policy-${userpass}`;
|
||||
const policy = `
|
||||
path "${db}/" {
|
||||
capabilities = ["list"]
|
||||
}
|
||||
path "${this.db}/roles" {
|
||||
path "${db}/roles" {
|
||||
capabilities = ["read","list"]
|
||||
}
|
||||
`;
|
||||
await runCmd([
|
||||
mountAuthCmd('userpass', this.userpass),
|
||||
mountEngineCmd('database', this.db),
|
||||
createPolicyCmd(this.policyName, this.policy),
|
||||
`write auth/${this.userpass}/users/${this.user} password=${this.user} token_policies=${this.policyName}`,
|
||||
]);
|
||||
return await logout();
|
||||
});
|
||||
|
||||
hooks.afterEach(async function () {
|
||||
await visit(`/vault/logout?namespace=${this.ns}`);
|
||||
await fillIn(AUTH_FORM.namespaceInput, ''); // clear login form namespace input
|
||||
await login();
|
||||
await runCmd([`delete sys/namespaces/${this.ns}`], false);
|
||||
});
|
||||
|
||||
// this test is specifically to cover a token renewal bug within namespaces
|
||||
// namespace_path isn't returned by the renew-self response and so the auth service was
|
||||
// incorrectly setting userRootNamespace to '' (which denotes 'root')
|
||||
// making subsequent capability checks fail because they would not be queried with the appropriate namespace header
|
||||
// if this test fails because a POST /v1/sys/capabilities-self returns a 403, then we have a problem!
|
||||
test('it sets namespace when renewing token', async function (assert) {
|
||||
await login();
|
||||
await runCmd([
|
||||
mountAuthCmd('userpass', this.userpass),
|
||||
mountEngineCmd('database', this.db),
|
||||
createPolicyCmd(this.policyName, this.policy),
|
||||
`write auth/${this.userpass}/users/${this.user} password=${this.user} token_policies=${this.policyName}`,
|
||||
mountAuthCmd('userpass', userpass),
|
||||
mountEngineCmd('database', db),
|
||||
createPolicyCmd(policyName, policy),
|
||||
`write auth/${userpass}/users/${user} password=${user} token_policies=${policyName}`,
|
||||
]);
|
||||
|
||||
const inputValues = {
|
||||
username: this.user,
|
||||
password: this.user,
|
||||
'auth-form-mount-path': this.userpass,
|
||||
'auth-form-ns-input': this.ns,
|
||||
username: user,
|
||||
password: user,
|
||||
'auth-form-mount-path': userpass,
|
||||
'auth-form-ns-input': ns,
|
||||
};
|
||||
|
||||
// login as user just to get token (this is the only way to generate a token in the UI right now..)
|
||||
@ -276,8 +258,8 @@ module('Acceptance | auth', function (hooks) {
|
||||
const token = find('[data-test-copy-button]').getAttribute('data-test-copy-button');
|
||||
|
||||
// login with token to reproduce bug
|
||||
await loginNs(this.ns, token);
|
||||
await visit(`/vault/secrets/${this.db}/overview?namespace=${this.ns}`);
|
||||
await loginNs(ns, token);
|
||||
await visit(`/vault/secrets/${db}/overview?namespace=${ns}`);
|
||||
assert
|
||||
.dom('[data-test-overview-card="Roles"]')
|
||||
.hasText('Roles Create new', 'database overview renders');
|
||||
@ -289,9 +271,30 @@ module('Acceptance | auth', function (hooks) {
|
||||
await click(GENERAL.tab('overview'));
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
`/vault/secrets/${this.db}/overview?namespace=${this.ns}`,
|
||||
`/vault/secrets/${db}/overview?namespace=${ns}`,
|
||||
'it navigates to database overview'
|
||||
);
|
||||
|
||||
// cleanup
|
||||
await visit(`/vault/logout?namespace=${ns}`);
|
||||
await fillIn(AUTH_FORM.namespaceInput, ''); // clear login form namespace input
|
||||
await login();
|
||||
await runCmd([`delete sys/namespaces/${ns}`], false);
|
||||
});
|
||||
|
||||
test('it sets namespace header for sys/internal/ui/mounts request when namespace is inputted', async function (assert) {
|
||||
assert.expect(1);
|
||||
await visit('/vault/auth');
|
||||
|
||||
this.server.get('/sys/internal/ui/mounts', (schema, req) => {
|
||||
assert.strictEqual(
|
||||
req.requestHeaders['X-Vault-Namespace'],
|
||||
'admin',
|
||||
'request header contains expected namespace'
|
||||
);
|
||||
return { errors: ['permission denied'] };
|
||||
});
|
||||
await typeIn(AUTH_FORM.namespaceInput, 'admin');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -11,64 +11,16 @@ import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import { MFA_SELECTORS } from 'vault/tests/helpers/mfa/mfa-selectors';
|
||||
import { constraintId, setupTotpMfaResponse } from 'vault/tests/helpers/mfa/mfa-helpers';
|
||||
import { fillInLoginFields } from 'vault/tests/helpers/auth/auth-helpers';
|
||||
import { AUTH_METHOD_MAP, fillInLoginFields } from 'vault/tests/helpers/auth/auth-helpers';
|
||||
import { callbackData, windowStub } from 'vault/tests/helpers/oidc-window-stub';
|
||||
|
||||
const ENT_ONLY = ['saml'];
|
||||
|
||||
// See AUTH_METHOD_TEST_CASES for how request data maps to method types
|
||||
// authRequest is the request made on submit and what returns mfa_validation requirements (if any)
|
||||
// additionalRequest are any third party requests the auth method expects
|
||||
const REQUEST_DATA = {
|
||||
username: {
|
||||
loginData: { username: 'matilda', password: 'password' },
|
||||
stubRequests: (server, path) =>
|
||||
server.post(`/auth/${path}/login/matilda`, () => setupTotpMfaResponse(path)),
|
||||
},
|
||||
github: {
|
||||
loginData: { token: 'mysupersecuretoken' },
|
||||
stubRequests: (server, path) => server.post(`/auth/${path}/login`, () => setupTotpMfaResponse(path)),
|
||||
},
|
||||
oidc: {
|
||||
loginData: { role: 'some-dev' },
|
||||
hasPopupWindow: true,
|
||||
stubRequests: (server, path) => {
|
||||
server.get(`/auth/${path}/oidc/callback`, () => setupTotpMfaResponse(path));
|
||||
server.post(`/auth/${path}/oidc/auth_url`, () => ({
|
||||
data: { auth_url: 'http://dev-foo-bar.com' },
|
||||
}));
|
||||
},
|
||||
},
|
||||
saml: {
|
||||
loginData: { role: 'some-dev' },
|
||||
hasPopupWindow: true,
|
||||
stubRequests: (server, path) => {
|
||||
server.put(`/auth/${path}/token`, () => setupTotpMfaResponse(path));
|
||||
server.put(`/auth/${path}/sso_service_url`, () => ({
|
||||
data: { sso_service_url: 'http://sso-url.hashicorp.com/service', token_poll_id: '1234' },
|
||||
}));
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// maps auth type to request data (line breaks to help separate and clarify which methods share request paths)
|
||||
const AUTH_METHOD_TEST_CASES = [
|
||||
{ authType: 'github', options: REQUEST_DATA.github },
|
||||
|
||||
{ authType: 'userpass', options: REQUEST_DATA.username },
|
||||
{ authType: 'ldap', options: REQUEST_DATA.username },
|
||||
{ authType: 'okta', options: REQUEST_DATA.username },
|
||||
{ authType: 'radius', options: REQUEST_DATA.username },
|
||||
|
||||
{ authType: 'oidc', options: REQUEST_DATA.oidc },
|
||||
{ authType: 'jwt', options: REQUEST_DATA.oidc },
|
||||
|
||||
// ENTERPRISE ONLY
|
||||
{ authType: 'saml', options: REQUEST_DATA.saml },
|
||||
];
|
||||
|
||||
for (const method of AUTH_METHOD_TEST_CASES) {
|
||||
for (const method of AUTH_METHOD_MAP) {
|
||||
const { authType, options } = method;
|
||||
// token doesn't support MFA
|
||||
if (authType === 'token') continue;
|
||||
|
||||
const isEntMethod = ENT_ONLY.includes(authType);
|
||||
// adding "enterprise" to the module title filters it out of the test runner for the CE repo
|
||||
module(`Acceptance | auth | mfa ${authType}${isEntMethod ? ' enterprise' : ''}`, function (hooks) {
|
||||
@ -90,7 +42,7 @@ for (const method of AUTH_METHOD_TEST_CASES) {
|
||||
|
||||
test(`${authType}: it displays mfa requirement for default paths`, async function (assert) {
|
||||
this.mountPath = authType;
|
||||
options.stubRequests(this.server, this.mountPath);
|
||||
options.stubRequests(this.server, this.mountPath, setupTotpMfaResponse(this.mountPath));
|
||||
|
||||
const loginKeys = Object.keys(options.loginData);
|
||||
assert.expect(3 + loginKeys.length);
|
||||
@ -123,7 +75,7 @@ for (const method of AUTH_METHOD_TEST_CASES) {
|
||||
|
||||
test(`${authType}: it displays mfa requirement for custom paths`, async function (assert) {
|
||||
this.mountPath = `${authType}-custom`;
|
||||
options.stubRequests(this.server, this.mountPath);
|
||||
options.stubRequests(this.server, this.mountPath, setupTotpMfaResponse(this.mountPath));
|
||||
const loginKeys = Object.keys(options.loginData);
|
||||
assert.expect(3 + loginKeys.length);
|
||||
|
||||
@ -160,7 +112,7 @@ for (const method of AUTH_METHOD_TEST_CASES) {
|
||||
test(`${authType}: it submits mfa requirement for default paths`, async function (assert) {
|
||||
assert.expect(2);
|
||||
this.mountPath = authType;
|
||||
options.stubRequests(this.server, this.mountPath);
|
||||
options.stubRequests(this.server, this.mountPath, setupTotpMfaResponse(this.mountPath));
|
||||
|
||||
const expectedOtp = '12345';
|
||||
server.post('/sys/mfa/validate', async (_, req) => {
|
||||
@ -190,7 +142,7 @@ for (const method of AUTH_METHOD_TEST_CASES) {
|
||||
assert.expect(2);
|
||||
|
||||
this.mountPath = `${authType}-custom`;
|
||||
options.stubRequests(this.server, this.mountPath);
|
||||
options.stubRequests(this.server, this.mountPath, setupTotpMfaResponse(this.mountPath));
|
||||
|
||||
const expectedOtp = '12345';
|
||||
server.post('/sys/mfa/validate', async (_, req) => {
|
||||
|
||||
@ -8,12 +8,17 @@ 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]'),
|
||||
tabBtn: (method: string) => `[data-test-auth-method="${method}"] button`,
|
||||
description: '[data-test-description]',
|
||||
roleInput: '[data-test-role]',
|
||||
input: (item: string) => `[data-test-${item}]`, // i.e. jwt, role, token, password or username
|
||||
mountPathInput: '[data-test-auth-form-mount-path]',
|
||||
moreOptions: '[data-test-auth-form-options-toggle]',
|
||||
advancedSettings: '[data-test-auth-form-options-toggle] button',
|
||||
namespaceInput: '[data-test-auth-form-ns-input]',
|
||||
managedNsRoot: '[data-test-managed-namespace-root]',
|
||||
logo: '[data-test-auth-logo]',
|
||||
helpText: '[data-test-auth-helptext]',
|
||||
authForm: (type: string) => `[data-test-auth-form="${type}"]`,
|
||||
otherMethodsBtn: '[data-test-other-methods-button]',
|
||||
};
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
import { click, fillIn, visit } from '@ember/test-helpers';
|
||||
import VAULT_KEYS from 'vault/tests/helpers/vault-keys';
|
||||
import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors';
|
||||
import { Server } from 'miragejs';
|
||||
|
||||
const { rootToken } = VAULT_KEYS;
|
||||
|
||||
@ -67,3 +68,61 @@ export const fillInLoginFields = async (loginFields: LoginFields, { toggleOption
|
||||
await fillIn(AUTH_FORM.input(input), value);
|
||||
}
|
||||
};
|
||||
|
||||
// See AUTH_METHOD_MAP for how login data maps to method types,
|
||||
// stubRequests are the requests made on submit for that method type
|
||||
export const LOGIN_DATA = {
|
||||
token: {
|
||||
loginData: { token: 'mytoken' },
|
||||
stubRequests: (server: Server, response: object) => server.get('/auth/token/lookup-self', () => response),
|
||||
},
|
||||
username: {
|
||||
loginData: { username: 'matilda', password: 'password' },
|
||||
stubRequests: (server: Server, path: string, response: object) =>
|
||||
server.post(`/auth/${path}/login/matilda`, () => response),
|
||||
},
|
||||
github: {
|
||||
loginData: { token: 'mysupersecuretoken' },
|
||||
stubRequests: (server: Server, path: string, response: object) =>
|
||||
server.post(`/auth/${path}/login`, () => response),
|
||||
},
|
||||
oidc: {
|
||||
loginData: { role: 'some-dev' },
|
||||
hasPopupWindow: true,
|
||||
stubRequests: (server: Server, path: string, response: object) => {
|
||||
server.get(`/auth/${path}/oidc/callback`, () => response);
|
||||
server.post(`/auth/${path}/oidc/auth_url`, () => {
|
||||
return { data: { auth_url: 'http://dev-foo-bar.com' } };
|
||||
});
|
||||
},
|
||||
},
|
||||
saml: {
|
||||
loginData: { role: 'some-dev' },
|
||||
hasPopupWindow: true,
|
||||
stubRequests: (server: Server, path: string, response: object) => {
|
||||
server.put(`/auth/${path}/token`, () => response);
|
||||
server.put(`/auth/${path}/sso_service_url`, () => {
|
||||
return { data: { sso_service_url: 'http://sso-url.hashicorp.com/service', token_poll_id: '1234' } };
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// maps auth type to request data
|
||||
export const AUTH_METHOD_MAP = [
|
||||
{ authType: 'token', options: LOGIN_DATA.token },
|
||||
{ authType: 'github', options: LOGIN_DATA.github },
|
||||
|
||||
// username and password methods
|
||||
{ authType: 'userpass', options: LOGIN_DATA.username },
|
||||
{ authType: 'ldap', options: LOGIN_DATA.username },
|
||||
{ authType: 'okta', options: LOGIN_DATA.username },
|
||||
{ authType: 'radius', options: LOGIN_DATA.username },
|
||||
|
||||
// oidc
|
||||
{ authType: 'oidc', options: LOGIN_DATA.oidc },
|
||||
{ authType: 'jwt', options: LOGIN_DATA.oidc },
|
||||
|
||||
// ENTERPRISE ONLY
|
||||
{ authType: 'saml', options: LOGIN_DATA.saml },
|
||||
];
|
||||
|
||||
89
ui/tests/integration/components/auth/fields-test.js
Normal file
89
ui/tests/integration/components/auth/fields-test.js
Normal file
@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import { find, render } from '@ember/test-helpers';
|
||||
import { capitalize } from '@ember/string';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
|
||||
module('Integration | Component | auth | fields', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.loginFields = [
|
||||
{ name: 'username' },
|
||||
{ name: 'role', helperText: 'Wow neat role!' },
|
||||
{ name: 'token', label: 'Super secret token' },
|
||||
{ name: 'password' },
|
||||
];
|
||||
this.renderComponent = () => {
|
||||
return render(hbs`<Auth::Fields @loginFields={{this.loginFields}} />`);
|
||||
};
|
||||
});
|
||||
|
||||
test('it renders field name as input label if "label" key is not specified', async function (assert) {
|
||||
await this.renderComponent();
|
||||
for (const field of ['username', 'password', 'role']) {
|
||||
const id = find(GENERAL.inputByAttr(field)).id;
|
||||
assert
|
||||
.dom(`#label-${id}`)
|
||||
.hasText(capitalize(field), `${field} it renders name if "label" key is not present`);
|
||||
}
|
||||
});
|
||||
|
||||
test('it does NOT render "helperText" if not present', async function (assert) {
|
||||
await this.renderComponent();
|
||||
for (const field of ['username', 'password', 'token']) {
|
||||
const id = find(GENERAL.inputByAttr(field)).id;
|
||||
assert
|
||||
.dom(`#helper-text-${id}`)
|
||||
.doesNotExist(`${field}: it does not render helperText if key is not present`);
|
||||
}
|
||||
});
|
||||
|
||||
test('it renders "helperText" if specified', async function (assert) {
|
||||
await this.renderComponent();
|
||||
const id = find(GENERAL.inputByAttr('role')).id;
|
||||
assert.dom(`#helper-text-${id}`).hasText('Wow neat role!');
|
||||
});
|
||||
|
||||
test('it renders "label" if specified', async function (assert) {
|
||||
await this.renderComponent();
|
||||
const id = find(GENERAL.inputByAttr('token')).id;
|
||||
assert.dom(`#label-${id}`).hasText('Super secret token', 'it renders "label" instead of "name"');
|
||||
});
|
||||
|
||||
test('it renders password input types for token and password fields', async function (assert) {
|
||||
await this.renderComponent();
|
||||
assert.dom(GENERAL.inputByAttr('token')).hasAttribute('type', 'password');
|
||||
assert.dom(GENERAL.inputByAttr('password')).hasAttribute('type', 'password');
|
||||
});
|
||||
|
||||
test('it renders text input types for other fields', async function (assert) {
|
||||
await this.renderComponent();
|
||||
assert.dom(GENERAL.inputByAttr('username')).hasAttribute('type', 'text');
|
||||
assert.dom(GENERAL.inputByAttr('role')).hasAttribute('type', 'text');
|
||||
});
|
||||
|
||||
test('it renders expected autocomplete values', async function (assert) {
|
||||
await this.renderComponent();
|
||||
const expectedValues = {
|
||||
username: 'username',
|
||||
role: 'role',
|
||||
token: 'off',
|
||||
password: 'current-password',
|
||||
};
|
||||
|
||||
for (const field of this.loginFields) {
|
||||
const { name } = field;
|
||||
const expected = expectedValues[name];
|
||||
assert
|
||||
.dom(GENERAL.inputByAttr(name))
|
||||
.hasAttribute('autocomplete', expected, `${name}: it renders autocomplete value "${expected}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
427
ui/tests/integration/components/auth/form-template-test.js
Normal file
427
ui/tests/integration/components/auth/form-template-test.js
Normal file
@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { click, fillIn, find, findAll, render, typeIn } from '@ember/test-helpers';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import sinon from 'sinon';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors';
|
||||
import { AUTH_METHOD_MAP } from 'vault/tests/helpers/auth/auth-helpers';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import {
|
||||
ALL_LOGIN_METHODS,
|
||||
BASE_LOGIN_METHODS,
|
||||
ENTERPRISE_LOGIN_METHODS,
|
||||
} from 'vault/utils/supported-login-methods';
|
||||
import { Response } from 'miragejs';
|
||||
|
||||
module('Integration | Component | auth | form template', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.version = this.owner.lookup('service:version');
|
||||
this.cluster = { id: '1' };
|
||||
this.wrappedToken = '';
|
||||
this.namespaceQueryParam = '';
|
||||
this.oidcProviderQueryParam = '';
|
||||
this.onAuthResponse = sinon.spy();
|
||||
this.onNamespaceChange = sinon.spy();
|
||||
|
||||
this.renderComponent = () => {
|
||||
return render(hbs`
|
||||
<Auth::FormTemplate
|
||||
@wrappedToken={{this.wrappedToken}}
|
||||
@oidcProviderQueryParam={{this.oidcProviderQueryParam}}
|
||||
@cluster={{this.cluster}}
|
||||
@handleNamespaceUpdate={{this.onNamespaceChange}}
|
||||
@namespace={{this.namespaceQueryParam}}
|
||||
@onSuccess={{this.onAuthResponse}}
|
||||
/>`);
|
||||
};
|
||||
});
|
||||
|
||||
test('it selects token by default', async function (assert) {
|
||||
await this.renderComponent();
|
||||
assert.dom(GENERAL.selectByAttr('auth type')).hasValue('token');
|
||||
});
|
||||
|
||||
test('it does not show toggle buttons when listing visibility is not set', async function (assert) {
|
||||
await this.renderComponent();
|
||||
assert.dom(GENERAL.backButton).doesNotExist('"Back" button does not render');
|
||||
assert.dom(AUTH_FORM.otherMethodsBtn).doesNotExist('"Sign in with other methods" does not render ');
|
||||
});
|
||||
|
||||
test('it calls sys/internal/ui/mounts on initial render', async function (assert) {
|
||||
assert.expect(2);
|
||||
this.server.get('/sys/internal/ui/mounts', (_, req) => {
|
||||
assert.true(true, 'request is made to /sys/internal/ui/mounts');
|
||||
assert.strictEqual(
|
||||
req.requestHeaders['X-Vault-Namespace'],
|
||||
undefined,
|
||||
'it does not pass a namespace header'
|
||||
);
|
||||
return {};
|
||||
});
|
||||
|
||||
await this.renderComponent();
|
||||
});
|
||||
|
||||
test('it fails gracefully if sys/internal/ui/mounts request errors', async function (assert) {
|
||||
assert.expect(2);
|
||||
this.server.get('/sys/internal/ui/mounts', () => {
|
||||
assert.true(true, 'request is made to /sys/internal/ui/mounts');
|
||||
return new Response(500, {}, { errors: ['something wrong with urls'] });
|
||||
});
|
||||
await this.renderComponent();
|
||||
assert.dom(GENERAL.selectByAttr('auth type')).exists();
|
||||
});
|
||||
|
||||
test('it displays errors', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await click(AUTH_FORM.login);
|
||||
// this error message text is because the auth service is not stubbed in this test
|
||||
assert.dom(GENERAL.messageError).hasText('Error Authentication failed: permission denied');
|
||||
});
|
||||
|
||||
module('listing visibility', function (hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.server.get('/sys/internal/ui/mounts', () => {
|
||||
return {
|
||||
data: {
|
||||
auth: {
|
||||
'userpass/': {
|
||||
description: '',
|
||||
options: {},
|
||||
type: 'userpass',
|
||||
},
|
||||
'userpass2/': {
|
||||
description: '',
|
||||
options: {},
|
||||
type: 'userpass',
|
||||
},
|
||||
'my-oidc/': {
|
||||
description: '',
|
||||
options: {},
|
||||
type: 'oidc',
|
||||
},
|
||||
'token/': {
|
||||
description: 'token based credentials',
|
||||
options: null,
|
||||
type: 'token',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
test('it renders mounts configured with listing_visibility="unuath"', async function (assert) {
|
||||
const expectedTabs = [
|
||||
{ type: 'userpass', display: 'Username' },
|
||||
{ type: 'oidc', display: 'OIDC' },
|
||||
{ type: 'token', display: 'Token' },
|
||||
];
|
||||
await this.renderComponent();
|
||||
assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist('dropdown does not render');
|
||||
// there are 4 mount paths returned in the stubbed sys/internal/ui/mounts response above,
|
||||
// but two are of the same type so only expect 3 tabs
|
||||
assert.dom(AUTH_FORM.tabs()).exists({ count: 3 }, 'it groups mount paths by type and renders 3 tabs');
|
||||
expectedTabs.forEach((m) => {
|
||||
assert.dom(AUTH_FORM.tabs(m.type)).exists(`${m.type} renders as a tab`);
|
||||
assert.dom(AUTH_FORM.tabs(m.type)).hasText(m.display, `${m.type} renders expected display name`);
|
||||
});
|
||||
});
|
||||
|
||||
test('it selects each auth tab and renders form for that type', async function (assert) {
|
||||
await this.renderComponent();
|
||||
const assertSelected = (type) => {
|
||||
assert.dom(AUTH_FORM.authForm(type)).exists(`${type}: form renders when tab is selected`);
|
||||
assert.dom(AUTH_FORM.tabBtn(type)).hasAttribute('aria-selected', 'true');
|
||||
};
|
||||
const assertUnselected = (type) => {
|
||||
assert.dom(AUTH_FORM.authForm(type)).doesNotExist(`${type}: form does NOT render`);
|
||||
assert.dom(AUTH_FORM.tabBtn(type)).hasAttribute('aria-selected', 'false');
|
||||
};
|
||||
// click through each tab
|
||||
await click(AUTH_FORM.tabBtn('userpass'));
|
||||
assertSelected('userpass');
|
||||
assertUnselected('oidc');
|
||||
assertUnselected('token');
|
||||
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
|
||||
|
||||
await click(AUTH_FORM.tabBtn('oidc'));
|
||||
assertSelected('oidc');
|
||||
assertUnselected('token');
|
||||
assertUnselected('userpass');
|
||||
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
|
||||
|
||||
await click(AUTH_FORM.tabBtn('token'));
|
||||
assertSelected('token');
|
||||
assertUnselected('oidc');
|
||||
assertUnselected('userpass');
|
||||
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
|
||||
});
|
||||
|
||||
test('it renders the mount description', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await click(AUTH_FORM.tabBtn('token'));
|
||||
assert.dom('section p').hasText('token based credentials data-test-description');
|
||||
});
|
||||
|
||||
test('it renders a dropdown if multiple mount paths are returned', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await click(AUTH_FORM.tabBtn('userpass'));
|
||||
const dropdownOptions = findAll(`${GENERAL.selectByAttr('path')} option`).map((o) => o.value);
|
||||
const expectedPaths = ['userpass/', 'userpass2/'];
|
||||
expectedPaths.forEach((p) => {
|
||||
assert.true(dropdownOptions.includes(p), `dropdown includes path: ${p}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('it renders a readonly input if only one mount path is returned', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await click(AUTH_FORM.tabBtn('oidc'));
|
||||
assert.dom(GENERAL.inputByAttr('path')).hasAttribute('readonly');
|
||||
assert.dom(GENERAL.inputByAttr('path')).hasValue('my-oidc/');
|
||||
});
|
||||
|
||||
test('it clicks "Sign in with other methods"', async function (assert) {
|
||||
await this.renderComponent();
|
||||
assert.dom(AUTH_FORM.tabs()).exists({ count: 3 }, 'tabs render by default');
|
||||
assert.dom(GENERAL.backButton).doesNotExist();
|
||||
await click(AUTH_FORM.otherMethodsBtn);
|
||||
assert
|
||||
.dom(AUTH_FORM.otherMethodsBtn)
|
||||
.doesNotExist('"Sign in with other methods" does not render after it is clicked');
|
||||
assert
|
||||
.dom(GENERAL.selectByAttr('auth type'))
|
||||
.exists('clicking "Sign in with other methods" renders dropdown instead of tabs');
|
||||
await click(GENERAL.backButton);
|
||||
assert.dom(GENERAL.backButton).doesNotExist('"Back" button does not render after it is clicked');
|
||||
assert.dom(AUTH_FORM.tabs()).exists({ count: 3 }, 'clicking "Back" renders tabs again');
|
||||
assert.dom(AUTH_FORM.otherMethodsBtn).exists('"Sign in with other methods" renders again');
|
||||
});
|
||||
|
||||
test('it resets selected tab after clicking "Sign in with other methods" and then "Back"', async function (assert) {
|
||||
await this.renderComponent();
|
||||
assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true');
|
||||
assert.dom(AUTH_FORM.tabBtn('oidc')).hasAttribute('aria-selected', 'false');
|
||||
assert.dom(AUTH_FORM.tabBtn('token')).hasAttribute('aria-selected', 'false');
|
||||
|
||||
// select a different tab before clicking "Sign in with other methods"
|
||||
await click(AUTH_FORM.tabBtn('oidc'));
|
||||
assert.dom(AUTH_FORM.tabBtn('oidc')).hasAttribute('aria-selected', 'true');
|
||||
assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'false');
|
||||
await click(AUTH_FORM.otherMethodsBtn);
|
||||
assert.dom(GENERAL.selectByAttr('auth type')).exists('it renders dropdown instead of tabs');
|
||||
await click(GENERAL.backButton);
|
||||
// assert tab selection is reset
|
||||
assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true');
|
||||
assert.dom(AUTH_FORM.tabBtn('oidc')).hasAttribute('aria-selected', 'false');
|
||||
assert.dom(AUTH_FORM.tabBtn('token')).hasAttribute('aria-selected', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
module('community', function (hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.version.type = 'community';
|
||||
});
|
||||
|
||||
test('it does not render the namespace input on community', async function (assert) {
|
||||
await this.renderComponent();
|
||||
assert.dom(GENERAL.inputByAttr('namespace')).doesNotExist();
|
||||
});
|
||||
|
||||
test('dropdown does not include enterprise methods', async function (assert) {
|
||||
const supported = BASE_LOGIN_METHODS.map((m) => m.type);
|
||||
const unsupported = ENTERPRISE_LOGIN_METHODS.map((m) => m.type);
|
||||
assert.expect(supported.length + unsupported.length);
|
||||
await this.renderComponent();
|
||||
const dropdownOptions = findAll(`${GENERAL.selectByAttr('auth type')} option`).map((o) => o.value);
|
||||
|
||||
supported.forEach((m) => {
|
||||
assert.true(dropdownOptions.includes(m), `dropdown includes supported method: ${m}`);
|
||||
});
|
||||
unsupported.forEach((m) => {
|
||||
assert.false(dropdownOptions.includes(m), `dropdown does NOT include unsupported method: ${m}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// tests with "enterprise" in the title are filtered out from CE test runs
|
||||
// naming the module 'ent' so these tests still run on the CE repo
|
||||
module('ent', function (hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.version.type = 'enterprise';
|
||||
this.version.features = ['Namespaces'];
|
||||
this.namespaceQueryParam = '';
|
||||
});
|
||||
|
||||
// in th ent module to test ALL supported login methods
|
||||
// iterating in tests should generally be avoided, but purposefully wanted to test the component
|
||||
// renders as expected as auth types change
|
||||
test('it selects each supported auth type and renders its form and relevant fields', async function (assert) {
|
||||
const fieldCount = AUTH_METHOD_MAP.map((m) => Object.keys(m.options.loginData).length);
|
||||
const sum = fieldCount.reduce((a, b) => a + b, 0);
|
||||
const methodCount = AUTH_METHOD_MAP.length;
|
||||
// 3 assertions per method, plus an assertion for each expected field
|
||||
assert.expect(3 * methodCount + sum); // count at time of writing is 40
|
||||
|
||||
await this.renderComponent();
|
||||
for (const method of AUTH_METHOD_MAP) {
|
||||
const { authType, options } = method;
|
||||
|
||||
const fields = Object.keys(options.loginData);
|
||||
await fillIn(GENERAL.selectByAttr('auth type'), authType);
|
||||
|
||||
assert.dom(GENERAL.selectByAttr('auth type')).hasValue(authType), `${authType}: it selects type`;
|
||||
assert.dom(AUTH_FORM.authForm(authType)).exists(`${authType}: it renders form component`);
|
||||
|
||||
// token is the only method that does not support a custom mount path
|
||||
if (authType !== 'token') {
|
||||
// jwt and oidc render the same component so the toggle remains open switching between those types
|
||||
const element = find(AUTH_FORM.advancedSettings);
|
||||
if (element.ariaExpanded === 'false') {
|
||||
await click(AUTH_FORM.advancedSettings);
|
||||
}
|
||||
}
|
||||
|
||||
const assertion = authType === 'token' ? 'doesNotExist' : 'exists';
|
||||
assert.dom(GENERAL.inputByAttr('path'))[assertion](`${authType}: mount path input ${assertion}`);
|
||||
|
||||
fields.forEach((field) => {
|
||||
assert.dom(GENERAL.inputByAttr(field)).exists(`${authType}: ${field} input renders`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('it disables namespace input when an oidc provider query param exists', async function (assert) {
|
||||
this.oidcProviderQueryParam = 'myprovider';
|
||||
await this.renderComponent();
|
||||
assert.dom(GENERAL.inputByAttr('namespace')).isDisabled();
|
||||
});
|
||||
|
||||
test('dropdown includes enterprise methods', async function (assert) {
|
||||
const supported = ALL_LOGIN_METHODS.map((m) => m.type);
|
||||
assert.expect(supported.length);
|
||||
await this.renderComponent();
|
||||
|
||||
const dropdownOptions = findAll(`${GENERAL.selectByAttr('auth type')} option`).map((o) => o.value);
|
||||
supported.forEach((m) => {
|
||||
assert.true(dropdownOptions.includes(m), `dropdown includes supported method: ${m}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('it re-requests mount data when a namespace is inputted', async function (assert) {
|
||||
assert.expect(3);
|
||||
const expectedNs = 'test-ns1';
|
||||
|
||||
let count = 0;
|
||||
this.server.get('/sys/internal/ui/mounts', () => {
|
||||
count++;
|
||||
const msg = count === 1 ? 'on initial render' : 'when namespace is inputted';
|
||||
assert.true(true, `/sys/internal/ui/mounts is called ${msg}`);
|
||||
return {};
|
||||
});
|
||||
|
||||
await this.renderComponent();
|
||||
await fillIn(GENERAL.inputByAttr('namespace'), expectedNs);
|
||||
const [actual] = this.onNamespaceChange.lastCall.args;
|
||||
assert.strictEqual(actual, expectedNs, 'callback has expected args');
|
||||
});
|
||||
|
||||
test('it re-requests mount data when namespace input is prefilled and then updated', async function (assert) {
|
||||
assert.expect(3);
|
||||
this.namespaceQueryParam = 'admin';
|
||||
const childNs = '/test-ns1';
|
||||
|
||||
let count = 0;
|
||||
this.server.get('/sys/internal/ui/mounts', () => {
|
||||
count++;
|
||||
const msg = count === 1 ? 'on initial render' : 'when namespace updates';
|
||||
assert.true(true, `/sys/internal/ui/mounts is called ${msg}`);
|
||||
return {};
|
||||
});
|
||||
|
||||
await this.renderComponent();
|
||||
await typeIn(GENERAL.inputByAttr('namespace'), childNs);
|
||||
const [actual] = this.onNamespaceChange.lastCall.args;
|
||||
assert.strictEqual(actual, `${this.namespaceQueryParam}${childNs}`, 'callback has expected args');
|
||||
});
|
||||
|
||||
test('it sets namespace for hvd managed clusters', async function (assert) {
|
||||
this.owner.lookup('service:flags').featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
|
||||
this.namespaceQueryParam = 'admin/west-coast';
|
||||
await this.renderComponent();
|
||||
assert.dom(AUTH_FORM.managedNsRoot).hasValue('/admin');
|
||||
assert.dom(AUTH_FORM.managedNsRoot).hasAttribute('readonly');
|
||||
assert.dom(GENERAL.inputByAttr('namespace')).hasValue('/west-coast');
|
||||
});
|
||||
|
||||
test('it does NOT display tabs when updated namespace has no visible mounts', async function (assert) {
|
||||
assert.expect(4);
|
||||
let count = 0;
|
||||
this.server.get('/sys/internal/ui/mounts', () => {
|
||||
count++;
|
||||
const mounts = {
|
||||
data: {
|
||||
auth: {
|
||||
'userpass2/': {
|
||||
description: '',
|
||||
options: {},
|
||||
type: 'userpass',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
// mocks re-requesting the endpoint when namespace changes by returning
|
||||
// mounts on initial request, then when a namespace is inputted a second request is made which return NO mounts
|
||||
const response = count === 1 ? mounts : {};
|
||||
return response;
|
||||
});
|
||||
|
||||
await this.renderComponent();
|
||||
assert.dom(AUTH_FORM.tabs('userpass')).exists('userpass renders as a tab');
|
||||
assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist('dropdown does not render');
|
||||
await fillIn(GENERAL.inputByAttr('namespace'), 'admin');
|
||||
assert.dom(AUTH_FORM.tabs()).doesNotExist('tabs do not render');
|
||||
assert.dom(GENERAL.selectByAttr('auth type')).exists('dropdown renders');
|
||||
});
|
||||
|
||||
test('it DOES display tabs when updated namespace has visible mounts', async function (assert) {
|
||||
assert.expect(4);
|
||||
let count = 0;
|
||||
this.server.get('/sys/internal/ui/mounts', () => {
|
||||
count++;
|
||||
const mounts = {
|
||||
data: {
|
||||
auth: {
|
||||
'userpass2/': {
|
||||
description: '',
|
||||
options: {},
|
||||
type: 'userpass',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
// mocks re-requesting the endpoint when namespace changes by returning
|
||||
// no mounts on initial request, then when a namespace is inputted a second request is made which return mounts
|
||||
const response = count === 1 ? {} : mounts;
|
||||
return response;
|
||||
});
|
||||
|
||||
await this.renderComponent();
|
||||
assert.dom(AUTH_FORM.tabs()).doesNotExist('tabs do not render');
|
||||
assert.dom(GENERAL.selectByAttr('auth type')).exists('dropdown renders');
|
||||
// fire off second request to sys/internal/mounts
|
||||
await fillIn(GENERAL.inputByAttr('namespace'), 'admin');
|
||||
assert.dom(AUTH_FORM.tabs('userpass')).exists('userpass renders as a tab');
|
||||
assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist('dropdown does not render');
|
||||
});
|
||||
});
|
||||
});
|
||||
122
ui/tests/integration/components/auth/form/base-test.js
Normal file
122
ui/tests/integration/components/auth/form/base-test.js
Normal file
@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import { find, render } from '@ember/test-helpers';
|
||||
import sinon from 'sinon';
|
||||
import testHelper from './test-helper';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
|
||||
// These auth types all use the default methods in auth/form/base
|
||||
// Any auth types with custom logic should be in a separate test file, i.e. okta
|
||||
|
||||
module('Integration | Component | auth | form | base', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.authenticateStub = sinon.stub(this.owner.lookup('service:auth'), 'authenticate');
|
||||
this.cluster = { id: 1 };
|
||||
this.onError = sinon.spy();
|
||||
this.onSuccess = sinon.spy();
|
||||
});
|
||||
|
||||
module('github', function (hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.authType = 'github';
|
||||
this.expectedFields = ['token'];
|
||||
this.renderComponent = () => {
|
||||
return render(hbs`
|
||||
<Auth::Form::Github
|
||||
@authType={{this.authType}}
|
||||
@cluster={{this.cluster}}
|
||||
@onError={{this.onError}}
|
||||
@onSuccess={{this.onSuccess}}
|
||||
/>`);
|
||||
};
|
||||
});
|
||||
|
||||
testHelper(test);
|
||||
|
||||
test('it renders custom label', async function (assert) {
|
||||
await this.renderComponent();
|
||||
const id = find(GENERAL.inputByAttr('token')).id;
|
||||
assert.dom(`#label-${id}`).hasText('Github token');
|
||||
});
|
||||
});
|
||||
|
||||
module('ldap', function (hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.authType = 'ldap';
|
||||
this.expectedFields = ['username', 'password'];
|
||||
this.renderComponent = () => {
|
||||
return render(hbs`
|
||||
<Auth::Form::Ldap
|
||||
@authType={{this.authType}}
|
||||
@cluster={{this.cluster}}
|
||||
@onError={{this.onError}}
|
||||
@onSuccess={{this.onSuccess}}
|
||||
/>`);
|
||||
};
|
||||
});
|
||||
|
||||
testHelper(test);
|
||||
});
|
||||
|
||||
module('radius', function (hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.authType = 'radius';
|
||||
this.expectedFields = ['username', 'password'];
|
||||
this.renderComponent = () => {
|
||||
return render(hbs`
|
||||
<Auth::Form::Radius
|
||||
@authType={{this.authType}}
|
||||
@cluster={{this.cluster}}
|
||||
@onError={{this.onError}}
|
||||
@onSuccess={{this.onSuccess}}
|
||||
/>`);
|
||||
};
|
||||
});
|
||||
|
||||
testHelper(test);
|
||||
});
|
||||
|
||||
module('token', function (hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.authType = 'token';
|
||||
this.expectedFields = ['token'];
|
||||
this.renderComponent = () => {
|
||||
return render(hbs`
|
||||
<Auth::Form::Token
|
||||
@authType={{this.authType}}
|
||||
@cluster={{this.cluster}}
|
||||
@onError={{this.onError}}
|
||||
@onSuccess={{this.onSuccess}}
|
||||
/>`);
|
||||
};
|
||||
});
|
||||
|
||||
testHelper(test);
|
||||
});
|
||||
|
||||
module('userpass', function (hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.authType = 'userpass';
|
||||
this.expectedFields = ['username', 'password'];
|
||||
this.renderComponent = () => {
|
||||
return render(hbs`
|
||||
<Auth::Form::Userpass
|
||||
@authType={{this.authType}}
|
||||
@cluster={{this.cluster}}
|
||||
@onError={{this.onError}}
|
||||
@onSuccess={{this.onSuccess}}
|
||||
/>`);
|
||||
};
|
||||
});
|
||||
|
||||
testHelper(test);
|
||||
});
|
||||
});
|
||||
61
ui/tests/integration/components/auth/form/oidc-jwt-test.js
Normal file
61
ui/tests/integration/components/auth/form/oidc-jwt-test.js
Normal file
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import { find, render } from '@ember/test-helpers';
|
||||
import sinon from 'sinon';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import testHelper from './test-helper';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
|
||||
module('Integration | Component | auth | form | oidc-jwt', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.expectedFields = ['role'];
|
||||
|
||||
this.authenticateStub = sinon.stub(this.owner.lookup('service:auth'), 'authenticate');
|
||||
this.cluster = { id: 1 };
|
||||
this.onError = sinon.spy();
|
||||
this.onSuccess = sinon.spy();
|
||||
this.renderComponent = () => {
|
||||
return render(hbs`
|
||||
<Auth::Form::OidcJwt
|
||||
@authType={{this.authType}}
|
||||
@cluster={{this.cluster}}
|
||||
@onError={{this.onError}}
|
||||
@onSuccess={{this.onSuccess}}
|
||||
/>
|
||||
`);
|
||||
};
|
||||
});
|
||||
|
||||
test('it renders helper text', async function (assert) {
|
||||
await this.renderComponent();
|
||||
const id = find(GENERAL.inputByAttr('role')).id;
|
||||
assert
|
||||
.dom(`#helper-text-${id}`)
|
||||
.hasText('Vault will use the default role to sign in if this field is left blank.');
|
||||
});
|
||||
|
||||
module('oidc', function (hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.authType = 'oidc';
|
||||
});
|
||||
|
||||
testHelper(test);
|
||||
});
|
||||
|
||||
module('jwt', function (hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.authType = 'jwt';
|
||||
});
|
||||
|
||||
testHelper(test);
|
||||
});
|
||||
});
|
||||
38
ui/tests/integration/components/auth/form/okta-test.js
Normal file
38
ui/tests/integration/components/auth/form/okta-test.js
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import { render } from '@ember/test-helpers';
|
||||
import sinon from 'sinon';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import testHelper from './test-helper';
|
||||
|
||||
module('Integration | Component | auth | form | okta', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.authType = 'okta';
|
||||
this.expectedFields = ['username', 'password'];
|
||||
|
||||
this.authenticateStub = sinon.stub(this.owner.lookup('service:auth'), 'authenticate');
|
||||
this.cluster = { id: 1 };
|
||||
this.onError = sinon.spy();
|
||||
this.onSuccess = sinon.spy();
|
||||
this.renderComponent = () => {
|
||||
return render(hbs`
|
||||
<Auth::Form::Okta
|
||||
@authType={{this.authType}}
|
||||
@cluster={{this.cluster}}
|
||||
@onError={{this.onError}}
|
||||
@onSuccess={{this.onSuccess}}
|
||||
/>`);
|
||||
};
|
||||
});
|
||||
|
||||
testHelper(test);
|
||||
});
|
||||
47
ui/tests/integration/components/auth/form/saml-test.js
Normal file
47
ui/tests/integration/components/auth/form/saml-test.js
Normal file
@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import { find, render } from '@ember/test-helpers';
|
||||
import sinon from 'sinon';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import testHelper from './test-helper';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
|
||||
module('Integration | Component | auth | form | saml', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.authType = 'saml';
|
||||
this.expectedFields = ['role'];
|
||||
|
||||
this.authenticateStub = sinon.stub(this.owner.lookup('service:auth'), 'authenticate');
|
||||
this.cluster = { id: 1 };
|
||||
this.onError = sinon.spy();
|
||||
this.onSuccess = sinon.spy();
|
||||
this.renderComponent = () => {
|
||||
return render(hbs`
|
||||
<Auth::Form::Saml
|
||||
@authType={{this.authType}}
|
||||
@cluster={{this.cluster}}
|
||||
@onError={{this.onError}}
|
||||
@onSuccess={{this.onSuccess}}
|
||||
/>`);
|
||||
};
|
||||
});
|
||||
|
||||
testHelper(test);
|
||||
|
||||
test('it renders helper text', async function (assert) {
|
||||
await this.renderComponent();
|
||||
const id = find(GENERAL.inputByAttr('role')).id;
|
||||
assert
|
||||
.dom(`#helper-text-${id}`)
|
||||
.hasText('Vault will use the default role to sign in if this field is left blank.');
|
||||
});
|
||||
});
|
||||
61
ui/tests/integration/components/auth/form/test-helper.js
Normal file
61
ui/tests/integration/components/auth/form/test-helper.js
Normal file
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { click, fillIn } from '@ember/test-helpers';
|
||||
import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors';
|
||||
import { AUTH_METHOD_MAP } from 'vault/tests/helpers/auth/auth-helpers';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
|
||||
/*
|
||||
NOTE: In the app these components are actually rendered dynamically by Auth::FormTemplate
|
||||
and so the components rendered in these tests does not represent "real world" situations.
|
||||
This is intentional to test component logic specific to auth/form/base or auth/form/<type>
|
||||
separately from auth/form-template.
|
||||
*/
|
||||
|
||||
export default (test) => {
|
||||
test('it renders fields', async function (assert) {
|
||||
await this.renderComponent();
|
||||
assert.dom(AUTH_FORM.authForm(this.authType)).exists(`${this.authType}: it renders form component`);
|
||||
this.expectedFields.forEach((field) => {
|
||||
assert.dom(GENERAL.inputByAttr(field)).exists(`${this.authType}: it renders ${field}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('it submits expected form data', async function (assert) {
|
||||
await this.renderComponent();
|
||||
const { options } = AUTH_METHOD_MAP.find((m) => m.authType === this.authType);
|
||||
const { loginData } = options;
|
||||
|
||||
for (const [field, value] of Object.entries(loginData)) {
|
||||
await fillIn(GENERAL.inputByAttr(field), value);
|
||||
}
|
||||
await click(AUTH_FORM.login);
|
||||
const [actual] = this.authenticateStub.lastCall.args;
|
||||
assert.propEqual(actual.data, loginData, 'auth service "authenticate" method is called with form data');
|
||||
});
|
||||
|
||||
test('it fires onError callback', async function (assert) {
|
||||
this.authenticateStub.throws('permission denied');
|
||||
await this.renderComponent();
|
||||
await click(AUTH_FORM.login);
|
||||
|
||||
const [actual] = this.onError.lastCall.args;
|
||||
assert.strictEqual(
|
||||
actual,
|
||||
'Authentication failed: permission denied: Sinon-provided permission denied',
|
||||
'it calls onError'
|
||||
);
|
||||
});
|
||||
|
||||
test('it fires onSuccess callback', async function (assert) {
|
||||
this.authenticateStub.returns('success!');
|
||||
await this.renderComponent();
|
||||
await click(AUTH_FORM.login);
|
||||
|
||||
const [actual] = this.onSuccess.lastCall.args;
|
||||
assert.strictEqual(actual, 'success!', 'it calls onSuccess');
|
||||
});
|
||||
};
|
||||
42
ui/types/vault/models/cluster.d.ts
vendored
Normal file
42
ui/types/vault/models/cluster.d.ts
vendored
Normal file
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { Model } from 'vault/app-types';
|
||||
|
||||
export default class ClusterModel extends Model {
|
||||
id: string;
|
||||
version: any;
|
||||
nodes: any;
|
||||
name: any;
|
||||
status: any;
|
||||
standby: any;
|
||||
type: any;
|
||||
license: any;
|
||||
hasChrootNamespace: any;
|
||||
replicationRedacted: any;
|
||||
get licenseExpiry(): any;
|
||||
get licenseState(): any;
|
||||
get needsInit(): any;
|
||||
get unsealed(): boolean;
|
||||
get sealed(): boolean;
|
||||
get leaderNode(): any;
|
||||
get sealThreshold(): any;
|
||||
get sealProgress(): any;
|
||||
get sealType(): any;
|
||||
get storageType(): any;
|
||||
get hcpLinkStatus(): any;
|
||||
get hasProgress(): boolean;
|
||||
get usingRaft(): boolean;
|
||||
mode: any;
|
||||
get allReplicationDisabled(): any;
|
||||
get anyReplicationEnabled(): any;
|
||||
dr: any;
|
||||
performance: any;
|
||||
rm: any;
|
||||
get drMode(): any;
|
||||
get replicationMode(): any;
|
||||
get replicationModeForDisplay(): 'Disaster Recovery' | 'Performance';
|
||||
get replicationIsInitializing(): boolean;
|
||||
}
|
||||
8
ui/types/vault/services/auth.d.ts
vendored
8
ui/types/vault/services/auth.d.ts
vendored
@ -21,4 +21,12 @@ export default class AuthService extends Service {
|
||||
authData: AuthData;
|
||||
currentToken: string;
|
||||
setLastFetch: (time: number) => void;
|
||||
handleError: (error: Error) => string | error[] | [error];
|
||||
authenticate(params: {
|
||||
clusterId: string;
|
||||
backend: string;
|
||||
data: Record<string, FormDataEntryValue | null>;
|
||||
selectedAuth: string;
|
||||
}): Promise<any>;
|
||||
mfaErrors: null | Errors[];
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user