\ No newline at end of file
diff --git a/ui/app/components/auth/tabs.hbs b/ui/app/components/auth/tabs.hbs
new file mode 100644
index 0000000000..c1e5193eac
--- /dev/null
+++ b/ui/app/components/auth/tabs.hbs
@@ -0,0 +1,45 @@
+{{!
+ Copyright (c) HashiCorp, Inc.
+ SPDX-License-Identifier: BUSL-1.1
+~}}
+
+
+ {{#each-in @authTabs as |methodType mounts|}}
+ {{@displayNameHelper methodType}}
+
+
+ {{! 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 }}
+
+ Mount path
+
+ {{#each mounts as |mount|}}
+
+ {{/each}}
+
+
+ {{else}}
+ {{#let (get mounts "0") as |mount|}}
+ {{#if (and (eq @selectedAuthMethod "token") mount.description)}}
+ {{! the token auth method does't support a custom path }}
+ {{mount.description}} data-test-description
+ {{else}}
+ {{! if it's the only available mount path render a readonly input }}
+
+ Mount path
+ {{#if mount.description}}
+ {{mount.description}}
+ {{/if}}
+
+ {{/if}}
+ {{/let}}
+ {{/if}}
+ {{/if}}
+
+
+ {{/each-in}}
+
\ No newline at end of file
diff --git a/ui/app/styles/helper-classes/colors.scss b/ui/app/styles/helper-classes/colors.scss
index 0d1056b434..179aa7fcd2 100644
--- a/ui/app/styles/helper-classes/colors.scss
+++ b/ui/app/styles/helper-classes/colors.scss
@@ -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;
}
diff --git a/ui/app/styles/utils/_color_variables.scss b/ui/app/styles/utils/_color_variables.scss
index 7b714b2d14..05986d5a43 100644
--- a/ui/app/styles/utils/_color_variables.scss
+++ b/ui/app/styles/utils/_color_variables.scss
@@ -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;
diff --git a/ui/app/utils/supported-login-methods.ts b/ui/app/utils/supported-login-methods.ts
new file mode 100644
index 0000000000..244dc1d76f
--- /dev/null
+++ b/ui/app/utils/supported-login-methods.ts
@@ -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);
diff --git a/ui/tests/acceptance/auth-list-test.js b/ui/tests/acceptance/auth/auth-list-test.js
similarity index 100%
rename from ui/tests/acceptance/auth-list-test.js
rename to ui/tests/acceptance/auth/auth-list-test.js
diff --git a/ui/tests/acceptance/auth-test.js b/ui/tests/acceptance/auth/auth-test.js
similarity index 82%
rename from ui/tests/acceptance/auth-test.js
rename to ui/tests/acceptance/auth/auth-test.js
index 6c315607c9..261483e5e6 100644
--- a/ui/tests/acceptance/auth-test.js
+++ b/ui/tests/acceptance/auth/auth-test.js
@@ -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');
});
});
});
diff --git a/ui/tests/acceptance/auth/mfa-test.js b/ui/tests/acceptance/auth/mfa-test.js
index fc0a81aa8e..634172977c 100644
--- a/ui/tests/acceptance/auth/mfa-test.js
+++ b/ui/tests/acceptance/auth/mfa-test.js
@@ -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) => {
diff --git a/ui/tests/helpers/auth/auth-form-selectors.ts b/ui/tests/helpers/auth/auth-form-selectors.ts
index 7ae91c570f..ab40f1b16b 100644
--- a/ui/tests/helpers/auth/auth-form-selectors.ts
+++ b/ui/tests/helpers/auth/auth-form-selectors.ts
@@ -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]',
};
diff --git a/ui/tests/helpers/auth/auth-helpers.ts b/ui/tests/helpers/auth/auth-helpers.ts
index e34d86d798..233c3ebf71 100644
--- a/ui/tests/helpers/auth/auth-helpers.ts
+++ b/ui/tests/helpers/auth/auth-helpers.ts
@@ -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 },
+];
diff --git a/ui/tests/integration/components/auth/fields-test.js b/ui/tests/integration/components/auth/fields-test.js
new file mode 100644
index 0000000000..e1860d43fa
--- /dev/null
+++ b/ui/tests/integration/components/auth/fields-test.js
@@ -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``);
+ };
+ });
+
+ 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}"`);
+ }
+ });
+});
diff --git a/ui/tests/integration/components/auth/form-template-test.js b/ui/tests/integration/components/auth/form-template-test.js
new file mode 100644
index 0000000000..74d8c5f207
--- /dev/null
+++ b/ui/tests/integration/components/auth/form-template-test.js
@@ -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`
+ `);
+ };
+ });
+
+ 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');
+ });
+ });
+});
diff --git a/ui/tests/integration/components/auth/form/base-test.js b/ui/tests/integration/components/auth/form/base-test.js
new file mode 100644
index 0000000000..9c626e2abc
--- /dev/null
+++ b/ui/tests/integration/components/auth/form/base-test.js
@@ -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`
+ `);
+ };
+ });
+
+ 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`
+ `);
+ };
+ });
+
+ testHelper(test);
+ });
+
+ module('radius', function (hooks) {
+ hooks.beforeEach(function () {
+ this.authType = 'radius';
+ this.expectedFields = ['username', 'password'];
+ this.renderComponent = () => {
+ return render(hbs`
+ `);
+ };
+ });
+
+ testHelper(test);
+ });
+
+ module('token', function (hooks) {
+ hooks.beforeEach(function () {
+ this.authType = 'token';
+ this.expectedFields = ['token'];
+ this.renderComponent = () => {
+ return render(hbs`
+ `);
+ };
+ });
+
+ testHelper(test);
+ });
+
+ module('userpass', function (hooks) {
+ hooks.beforeEach(function () {
+ this.authType = 'userpass';
+ this.expectedFields = ['username', 'password'];
+ this.renderComponent = () => {
+ return render(hbs`
+ `);
+ };
+ });
+
+ testHelper(test);
+ });
+});
diff --git a/ui/tests/integration/components/auth/form/oidc-jwt-test.js b/ui/tests/integration/components/auth/form/oidc-jwt-test.js
new file mode 100644
index 0000000000..94ec8a705e
--- /dev/null
+++ b/ui/tests/integration/components/auth/form/oidc-jwt-test.js
@@ -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`
+
+ `);
+ };
+ });
+
+ 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);
+ });
+});
diff --git a/ui/tests/integration/components/auth/form/okta-test.js b/ui/tests/integration/components/auth/form/okta-test.js
new file mode 100644
index 0000000000..1d61951bdb
--- /dev/null
+++ b/ui/tests/integration/components/auth/form/okta-test.js
@@ -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`
+ `);
+ };
+ });
+
+ testHelper(test);
+});
diff --git a/ui/tests/integration/components/auth/form/saml-test.js b/ui/tests/integration/components/auth/form/saml-test.js
new file mode 100644
index 0000000000..dbc79acc6f
--- /dev/null
+++ b/ui/tests/integration/components/auth/form/saml-test.js
@@ -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`
+ `);
+ };
+ });
+
+ 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.');
+ });
+});
diff --git a/ui/tests/integration/components/auth/form/test-helper.js b/ui/tests/integration/components/auth/form/test-helper.js
new file mode 100644
index 0000000000..6ee55012cb
--- /dev/null
+++ b/ui/tests/integration/components/auth/form/test-helper.js
@@ -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/
+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');
+ });
+};
diff --git a/ui/types/vault/models/cluster.d.ts b/ui/types/vault/models/cluster.d.ts
new file mode 100644
index 0000000000..5b40ae7162
--- /dev/null
+++ b/ui/types/vault/models/cluster.d.ts
@@ -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;
+}
diff --git a/ui/types/vault/services/auth.d.ts b/ui/types/vault/services/auth.d.ts
index 510796c87b..0fd663c2eb 100644
--- a/ui/types/vault/services/auth.d.ts
+++ b/ui/types/vault/services/auth.d.ts
@@ -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;
+ selectedAuth: string;
+ }): Promise;
+ mfaErrors: null | Errors[];
}