claire bontempo 9832c90037
UI: Implement accessible auth form components (#30500)
* UI: Move `wrapped_token` login functionality to route (#30465)

* move token unwrap functionality to page component

* update mfa test

* remove wrapped_token logic from page component

* more cleanup to relocate unwrap logic

* move wrapped_token to route

* move unwrap tests to acceptance

* move mfa form back

* add some padding

* update mfa-form tests

* get param from params

* wait for auth form on back

* run rests

* UI: Add MFA support for SSO methods (#30489)

* initial implementation of mfa validation for sso methods

* update typescript interfaces

* add stopgap changes to auth service

* switch order backend is defined

* update login form for tests even though it will be deleted

* attempt to stabilize wrapped_query test

* =update login form test why not

* Update ui/app/components/auth/form/saml.ts

Co-authored-by: lane-wetmore <lane.wetmore@hashicorp.com>

---------

Co-authored-by: lane-wetmore <lane.wetmore@hashicorp.com>

* Move CSP error to page component (#30492)

* initial implementation of mfa validation for sso methods

* update typescript interfaces

* add stopgap changes to auth service

* switch order backend is defined

* update login form for tests even though it will be deleted

* attempt to stabilize wrapped_query test

* =update login form test why not

* move csp error to page component

* move csp error to page component

* Move fetching unauthenticated mounts to the route (#30509)

* rename namespace arg to namespaceQueryParam

* move fetch mounts to route

* add margin to sign in button spacing

* update selectors for oidc provider test

* add todo delete comments

* fix arg typo in test

* change method name

* fix args handling tab click

* remove tests that no longer relate to components functionality

* add tests for preselectedAuthType functionality

* move typescript interfaces, fix selector

* add await

* oops

* move format method down, make private

* move tab formatting to the route

* move to page object

* fix token unwrap aborting transition

* not sure what that is doing there..

* add comments

* rename to presetAuthType

* use did-insert instead

* UI: Implement `Auth::FormTemplate` (#30521)

* replace Auth::LoginForm with Auth::FormTemplate

* first round of test updates

* return null if mounts object is empty

* add comment and test for empty sys/internal/mounts data

* more test updates

* delete listing_visibility test, delete login-form component test

* update divs to Hds::Card::Container

* add overflow class

* remove unused getters

* move requesting stored auth type to page component

* fix typo

* Update ui/app/components/auth/form/oidc-jwt.ts

make comment make more sense

* small cleanup items, update imports

* Delete old auth components (#30527)

* delete old components

* update codeowners

* Update `with` query param functionality (#30537)

* update path input to type=hidden

* add test coverage

* update page test

* update auth route

* delete login form

* update ent test

* consolidate logic in getter

* add more comments

* more comments..

* rename selector

* refresh model as well

* redirect for invalid query params

* move unwrap to redirect

* only redirect on invalid query params

* add tests for query param

* test selector updates

* remove todos, update relevant ones with initials

* add changelog

---------

Co-authored-by: lane-wetmore <lane.wetmore@hashicorp.com>
2025-05-08 09:58:20 -07:00

247 lines
10 KiB
JavaScript

/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { click, fillIn, render, waitFor } 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 { fillInLoginFields, VISIBLE_MOUNTS } from 'vault/tests/helpers/auth/auth-helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { CSP_ERROR } from 'vault/components/auth/page';
module('Integration | Component | auth | page', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.version = this.owner.lookup('service:version');
this.cluster = { id: '1' };
this.onAuthSuccess = sinon.spy();
this.onNamespaceUpdate = sinon.spy();
this.visibleAuthMounts = false;
this.directLinkData = null;
this.renderComponent = () => {
return render(hbs`
<Auth::Page
@cluster={{this.cluster}}
@directLinkData={{this.directLinkData}}
@namespaceQueryParam={{this.nsQp}}
@oidcProviderQueryParam={{this.providerQp}}
@onAuthSuccess={{this.onAuthSuccess}}
@onNamespaceUpdate={{this.onNamespaceUpdate}}
@visibleAuthMounts={{this.visibleAuthMounts}}
/>
`);
};
// in the real world more info is returned by auth requests
// only including pertinent data for testing
this.authRequest = (url) => this.server.post(url, () => ({ auth: { policies: ['default'] } }));
});
test('it renders error on CSP violation', async function (assert) {
assert.expect(2);
this.cluster.standby = true;
await this.renderComponent();
assert.dom(GENERAL.pageError.error).doesNotExist();
this.owner.lookup('service:csp-event').handleEvent({ violatedDirective: 'connect-src' });
await waitFor(GENERAL.pageError.error);
assert.dom(GENERAL.pageError.error).hasText(CSP_ERROR);
});
test('it renders splash logo when oidc provider query param is present', async function (assert) {
this.providerQp = 'myprovider';
await this.renderComponent();
assert.dom(AUTH_FORM.logo).exists();
assert
.dom(AUTH_FORM.helpText)
.hasText(
'Once you log in, you will be redirected back to your application. If you require login credentials, contact your administrator.'
);
});
test('it disables namespace input when oidc provider query param is present', async function (assert) {
this.providerQp = 'myprovider';
this.version.features = ['Namespaces'];
await this.renderComponent();
assert.dom(AUTH_FORM.logo).exists();
assert.dom(GENERAL.inputByAttr('namespace')).isDisabled();
});
test('it calls onNamespaceUpdate', async function (assert) {
assert.expect(1);
this.version.features = ['Namespaces'];
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('namespace'), 'mynamespace');
const [actual] = this.onNamespaceUpdate.lastCall.args;
assert.strictEqual(actual, 'mynamespace', `onNamespaceUpdate called with: ${actual}`);
});
test('it calls onNamespaceUpdate for HVD managed clusters', async function (assert) {
assert.expect(2);
this.version.features = ['Namespaces'];
this.owner.lookup('service:flags').featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
this.nsQp = 'admin';
await this.renderComponent();
assert.dom(GENERAL.inputByAttr('namespace')).hasValue('');
await fillIn(GENERAL.inputByAttr('namespace'), 'mynamespace');
const [actual] = this.onNamespaceUpdate.lastCall.args;
assert.strictEqual(actual, 'mynamespace', `onNamespaceUpdate called with: ${actual}`);
});
module('listing visibility', function (hooks) {
hooks.beforeEach(function () {
this.visibleAuthMounts = VISIBLE_MOUNTS;
});
test('it formats tab data if visible auth mounts exist', async function (assert) {
await this.renderComponent();
const expectedTabs = [
{ type: 'userpass', display: 'Userpass' },
{ type: 'oidc', display: 'OIDC' },
{ type: 'token', display: 'Token' },
];
assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist('dropdown does not render');
// there are 4 mount paths returned in visibleAuthMounts 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.tabBtn(m.type)).exists(`${m.type} renders as a tab`);
assert.dom(AUTH_FORM.tabBtn(m.type)).hasText(m.display, `${m.type} renders expected display name`);
});
assert
.dom(AUTH_FORM.tabBtn('userpass'))
.hasAttribute('aria-selected', 'true', 'it selects the first type by default');
});
test('it selects type in the dropdown if @directLinkData references NON visible type', async function (assert) {
this.directLinkData = { type: 'ldap', hasMountData: false };
await this.renderComponent();
assert.dom(GENERAL.selectByAttr('auth type')).hasValue('ldap', 'dropdown has type selected');
assert.dom(AUTH_FORM.authForm('ldap')).exists();
assert.dom(GENERAL.inputByAttr('username')).exists();
assert.dom(GENERAL.inputByAttr('password')).exists();
await click(AUTH_FORM.advancedSettings);
assert.dom(GENERAL.inputByAttr('path')).exists();
assert.dom(AUTH_FORM.preferredMethod('LDAP')).doesNotExist('single mount view does not render');
assert.dom(AUTH_FORM.tabBtn('ldap')).doesNotExist('tab does not render');
assert
.dom(GENERAL.backButton)
.exists('back button renders because listing_visibility="unauth" for other mounts');
assert.dom(AUTH_FORM.otherMethodsBtn).doesNotExist('"Sign in with other methods" does not render');
});
test('it renders single mount view instead of tabs if @directLinkData data references a visible type', async function (assert) {
this.directLinkData = { path: 'my-oidc/', type: 'oidc', hasMountData: true };
await this.renderComponent();
assert.dom(AUTH_FORM.preferredMethod('OIDC')).hasText('OIDC', 'it renders mount type');
assert.dom(GENERAL.inputByAttr('role')).exists();
assert.dom(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden');
assert.dom(GENERAL.inputByAttr('path')).hasValue('my-oidc/');
assert.dom(AUTH_FORM.otherMethodsBtn).exists('"Sign in with other methods" renders');
assert.dom(AUTH_FORM.tabBtn('oidc')).doesNotExist('tab does not render');
assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist();
assert.dom(AUTH_FORM.advancedSettings).doesNotExist();
assert.dom(GENERAL.backButton).doesNotExist();
});
});
const REQUEST_DATA = {
username: {
loginData: { username: 'matilda', password: 'password' },
url: ({ path, username }) => `/auth/${path}/login/${username}`,
},
github: {
loginData: { token: 'mysupersecuretoken' },
url: ({ path }) => `/auth/${path}/login`,
},
};
// only testing methods that submit via AuthForm (and not separate, child component)
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 },
];
for (const { authType, options } of AUTH_METHOD_TEST_CASES) {
test(`${authType}: it calls onAuthSuccess on submit for default path`, async function (assert) {
assert.expect(1);
const { loginData, url } = options;
const requestUrl = url({ path: authType, username: loginData?.username });
this.authRequest(requestUrl);
await this.renderComponent();
await fillIn(AUTH_FORM.selectMethod, authType);
// await fillIn(AUTH_FORM.selectMethod, authType);
await fillInLoginFields(loginData);
await click(AUTH_FORM.login);
const [actual] = this.onAuthSuccess.lastCall.args;
const expected = {
namespace: '',
token: `vault-${authType}☃1`,
isRoot: false,
};
assert.propEqual(actual, expected, `onAuthSuccess called with: ${JSON.stringify(actual)}`);
});
test(`${authType}: it calls onAuthSuccess on submit for custom path`, async function (assert) {
assert.expect(1);
const customPath = `${authType}-custom`;
const { loginData, url } = options;
const loginDataWithPath = { ...loginData, path: customPath };
// pass custom path to request URL
const requestUrl = url({ path: customPath, username: loginData?.username });
this.authRequest(requestUrl);
await this.renderComponent();
await fillIn(AUTH_FORM.selectMethod, authType);
// await fillIn(AUTH_FORM.selectMethod, authType);
// toggle mount path input to specify custom path
await fillInLoginFields(loginDataWithPath, { toggleOptions: true });
await click(AUTH_FORM.login);
const [actual] = this.onAuthSuccess.lastCall.args;
const expected = {
namespace: '',
token: `vault-${authType}☃1`,
isRoot: false,
};
assert.propEqual(actual, expected, `onAuthSuccess called with: ${JSON.stringify(actual)}`);
});
}
// token makes a GET request so test separately
test('token: it calls onAuthSuccess on submit', async function (assert) {
assert.expect(1);
this.server.get('/auth/token/lookup-self', () => {
return { data: { policies: ['default'] } };
});
await this.renderComponent();
await fillIn(AUTH_FORM.selectMethod, 'token');
// await fillIn(AUTH_FORM.selectMethod, 'token');
await fillInLoginFields({ token: 'mysupersecuretoken' });
await click(AUTH_FORM.login);
const [actual] = this.onAuthSuccess.lastCall.args;
const expected = {
namespace: '',
token: `vault-token☃1`,
isRoot: false,
};
assert.propEqual(actual, expected, `onAuthSuccess called with: ${JSON.stringify(actual)}`);
});
});