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:
claire bontempo 2025-04-17 08:36:00 -07:00 committed by GitHub
parent 08c5a52b02
commit c4cfa371c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1801 additions and 105 deletions

View 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}}

View 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;
}
};
}

View 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}}

View 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;
}
})
);
}

View 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>

View 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);
}
}
}

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

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

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

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

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

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

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

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

View 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>

View 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>

View File

@ -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;
}

View File

@ -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;

View 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);

View File

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

View File

@ -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) => {

View File

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

View File

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

View 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}"`);
}
});
});

View 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');
});
});
});

View 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);
});
});

View 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);
});
});

View 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);
});

View 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.');
});
});

View 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
View 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;
}

View File

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