diff --git a/ui/app/services/auth.js b/ui/app/services/auth.js index 09cc82146e..5c4b477fe4 100644 --- a/ui/app/services/auth.js +++ b/ui/app/services/auth.js @@ -348,9 +348,10 @@ export default Service.extend({ displayName = (this.getTokenData(tokenName) || {}).displayName; } - // this is a workaround for OIDC/SAML methods WITH mfa configured. at this time mfa/validate endpoint does not - // return display_name (or metadata that includes it) for this auth combination. - // this if block can be removed if/when the API returns display_name on the mfa/validate response. + // this is a fallback for any methods that don't return a display name from the initial auth request (i.e. JWT) + // or for OIDC/SAML with mfa configured because the mfa/validate endpoint does not consistently + // return display_name (or metadata that includes something to be used as such). + // this if block can be removed if/when the API consistently returns a display_name. if (!displayName) { // if still nothing, request token data as a last resort try { diff --git a/ui/app/templates/vault/cluster/access/methods.hbs b/ui/app/templates/vault/cluster/access/methods.hbs index 0991b6f9d1..da7100ab9a 100644 --- a/ui/app/templates/vault/cluster/access/methods.hbs +++ b/ui/app/templates/vault/cluster/access/methods.hbs @@ -50,7 +50,7 @@
diff --git a/ui/tests/acceptance/access/methods-test.js b/ui/tests/acceptance/access/methods-test.js index 837985636f..7077b7bee4 100644 --- a/ui/tests/acceptance/access/methods-test.js +++ b/ui/tests/acceptance/access/methods-test.js @@ -10,9 +10,9 @@ import { setupApplicationTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { v4 as uuidv4 } from 'uuid'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; -import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; import { mountAuthCmd, runCmd } from 'vault/tests/helpers/commands'; import { login } from 'vault/tests/helpers/auth/auth-helpers'; +import { sanitizePath } from 'core/utils/sanitize-path'; const { searchSelect } = GENERAL; @@ -79,7 +79,7 @@ module('Acceptance | auth-methods list view', function (hooks) { await visit('/vault/access/'); for (const [key] of Object.entries(authPayload)) { assert - .dom(AUTH_FORM.linkedBlockAuth(key.replace(/\/$/, ''))) // remove the forward slash + .dom(GENERAL.linkedBlock(sanitizePath(key))) .exists({ count: 1 }, `auth method ${key} appears in list view`); } await visit('/vault/settings/auth/enable'); @@ -87,7 +87,7 @@ module('Acceptance | auth-methods list view', function (hooks) { await visit('/vault/access/'); for (const [key] of Object.entries(authPayload)) { assert - .dom(AUTH_FORM.linkedBlockAuth(key.replace(/\/$/, ''))) + .dom(GENERAL.linkedBlock(sanitizePath(key))) .exists({ count: 1 }, `auth method ${key} appears in list view after navigating from OIDC Provider`); } }); diff --git a/ui/tests/acceptance/auth/auth-list-test.js b/ui/tests/acceptance/auth/auth-list-test.js index a38c6d034c..6898d425d2 100644 --- a/ui/tests/acceptance/auth/auth-list-test.js +++ b/ui/tests/acceptance/auth/auth-list-test.js @@ -13,7 +13,6 @@ import { MANAGED_AUTH_BACKENDS } from 'vault/helpers/supported-managed-auth-back import { deleteAuthCmd, mountAuthCmd, runCmd, createNS } from 'vault/tests/helpers/commands'; import { methods } from 'vault/helpers/mountable-auth-methods'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; -import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; import { MOUNT_BACKEND_FORM } from 'vault/tests/helpers/components/mount-backend-form-selectors'; const SELECTORS = { @@ -44,7 +43,7 @@ module('Acceptance | auth backend list', function (hooks) { test('userpass secret backend', async function (assert) { // helper function to create a user in the specified backend async function createUser(backendPath, username) { - await click(AUTH_FORM.linkedBlockAuth(backendPath)); + await click(GENERAL.linkedBlock(backendPath)); assert.dom(GENERAL.emptyStateTitle).exists('shows empty state'); await click(SELECTORS.createUser); await fillIn(GENERAL.inputByAttr('username'), username); @@ -68,7 +67,7 @@ module('Acceptance | auth backend list', function (hooks) { // check that switching back to the first auth method shows the first user await click(SELECTORS.methods); - await click(AUTH_FORM.linkedBlockAuth(this.path1)); + await click(GENERAL.linkedBlock(this.path1)); assert.dom(SELECTORS.listItem).hasText(this.user1, 'user1 exists in the list'); }); @@ -99,8 +98,8 @@ module('Acceptance | auth backend list', function (hooks) { // check popup menu for auth method const itemCount = isTokenType ? 2 : 3; - const triggerSelector = `${AUTH_FORM.linkedBlockAuth(path)} [data-test-popup-menu-trigger]`; - const itemSelector = `${AUTH_FORM.linkedBlockAuth(path)} .hds-dropdown-list-item`; + const triggerSelector = `${GENERAL.linkedBlock(path)} [data-test-popup-menu-trigger]`; + const itemSelector = `${GENERAL.linkedBlock(path)} .hds-dropdown-list-item`; await click(triggerSelector); assert @@ -108,7 +107,7 @@ module('Acceptance | auth backend list', function (hooks) { .exists({ count: itemCount }, `shows ${itemCount} dropdown items for ${type}`); // check that auth methods are linkable - await click(AUTH_FORM.linkedBlockAuth(path)); + await click(GENERAL.linkedBlock(path)); if (!supportManaged.includes(type)) { assert.dom(GENERAL.linkTo('auth-tab')).exists({ count: 1 }); @@ -145,7 +144,7 @@ module('Acceptance | auth backend list', function (hooks) { await visit('/vault/access'); // all auth methods should be linkable - await click(AUTH_FORM.linkedBlockAuth(path)); + await click(GENERAL.linkedBlock(path)); assert.dom(GENERAL.linkTo('auth-tab')).exists({ count: 1 }); assert .dom(GENERAL.linkTo('auth-tab')) @@ -164,7 +163,7 @@ module('Acceptance | auth backend list', function (hooks) { await fillIn(GENERAL.inputByAttr('description'), 'My custom description'); await click(GENERAL.submitButton); assert.strictEqual(currentURL(), '/vault/access', 'successfully saves and navigates away'); - await click(AUTH_FORM.linkedBlockAuth('token')); + await click(GENERAL.linkedBlock('token')); assert .dom('[data-test-row-value="Description"]') .hasText('My custom description', 'description was saved'); diff --git a/ui/tests/acceptance/auth/mfa-test.js b/ui/tests/acceptance/auth/mfa-test.js deleted file mode 100644 index b7603c75f7..0000000000 --- a/ui/tests/acceptance/auth/mfa-test.js +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import { module, test } from 'qunit'; -import { setupApplicationTest } from 'ember-qunit'; -import { click, visit, fillIn } from '@ember/test-helpers'; -import { setupMirage } from 'ember-cli-mirage/test-support'; -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 { 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']; -const DELAY_IN_MS = 500; - -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) { - setupApplicationTest(hooks); - setupMirage(hooks); - - hooks.beforeEach(async function () { - if (options?.hasPopupWindow) { - this.windowStub = windowStub(); - } - await visit('/vault/auth'); - }); - - hooks.afterEach(function () { - if (options?.hasPopupWindow) { - this.windowStub.restore(); - } - }); - - test(`${authType}: it displays mfa requirement for default paths`, async function (assert) { - this.mountPath = authType; - options.stubRequests(this.server, this.mountPath, setupTotpMfaResponse(this.mountPath)); - - const loginKeys = Object.keys(options.loginData); - assert.expect(3 + loginKeys.length); - - // Fill in login form - await fillIn(AUTH_FORM.selectMethod, authType); - await fillInLoginFields(options.loginData); - - if (options?.hasPopupWindow) { - // fires "message" event which methods that rely on popup windows wait for - setTimeout(() => { - // set path which is used to set :mount param in the callback url => /auth/:mount/oidc/callback - window.postMessage(callbackData({ path: this.mountPath }), window.origin); - }, DELAY_IN_MS); - } - - await click(GENERAL.submitButton); - assert - .dom(MFA_SELECTORS.mfaForm) - .hasText( - 'Back Multi-factor authentication is enabled for your account. Enter your authentication code to log in. TOTP passcode Verify' - ); - await click(GENERAL.backButton); - assert.dom(AUTH_FORM.form).exists('clicking back returns to auth form'); - assert.dom(AUTH_FORM.selectMethod).hasValue(authType, 'preserves method type on back'); - for (const field of loginKeys) { - assert.dom(GENERAL.inputByAttr(field)).hasValue('', `${field} input clears on back`); - } - }); - - test(`${authType}: it displays mfa requirement for custom paths`, async function (assert) { - this.mountPath = `${authType}-custom`; - options.stubRequests(this.server, this.mountPath, setupTotpMfaResponse(this.mountPath)); - const loginKeys = Object.keys(options.loginData); - assert.expect(3 + loginKeys.length); - - // Fill in login form - await fillIn(AUTH_FORM.selectMethod, authType); - // Toggle more options to input a custom mount path - await fillInLoginFields({ ...options.loginData, path: this.mountPath }, { toggleOptions: true }); - - if (options?.hasPopupWindow) { - // fires "message" event which methods that rely on popup windows wait for - setTimeout(() => { - // set path which is used to set :mount param in the callback url => /auth/:mount/oidc/callback - window.postMessage(callbackData({ path: this.mountPath }), window.origin); - }, DELAY_IN_MS); - } - - await click(GENERAL.submitButton); - assert - .dom(MFA_SELECTORS.mfaForm) - .hasText( - 'Back Multi-factor authentication is enabled for your account. Enter your authentication code to log in. TOTP passcode Verify' - ); - await click(GENERAL.backButton); - assert.dom(AUTH_FORM.form).exists('clicking back returns to auth form'); - assert.dom(AUTH_FORM.selectMethod).hasValue(authType, 'preserves method type on back'); - for (const field of loginKeys) { - assert.dom(GENERAL.inputByAttr(field)).hasValue('', `${field} input clears on back`); - } - }); - - test(`${authType}: it submits mfa requirement for default paths`, async function (assert) { - assert.expect(2); - this.mountPath = authType; - options.stubRequests(this.server, this.mountPath, setupTotpMfaResponse(this.mountPath)); - - const expectedOtp = '12345'; - server.post('/sys/mfa/validate', async (_, req) => { - const [actualOtp] = JSON.parse(req.requestBody).mfa_payload[constraintId]; - assert.true(true, 'it makes request to mfa validate endpoint'); - assert.strictEqual(actualOtp, expectedOtp, 'payload contains otp'); - }); - - // Fill in login form - await fillIn(AUTH_FORM.selectMethod, authType); - await fillInLoginFields(options.loginData); - - if (options?.hasPopupWindow) { - // fires "message" event which methods that rely on popup windows wait for - setTimeout(() => { - // set path which is used to set :mount param in the callback url => /auth/:mount/oidc/callback - window.postMessage(callbackData({ path: this.mountPath }), window.origin); - }, DELAY_IN_MS); - } - - await click(GENERAL.submitButton); - await fillIn(MFA_SELECTORS.passcode(0), expectedOtp); - await click(MFA_SELECTORS.validate); - }); - - test(`${authType}: it submits mfa requirement for custom paths`, async function (assert) { - assert.expect(2); - - this.mountPath = `${authType}-custom`; - options.stubRequests(this.server, this.mountPath, setupTotpMfaResponse(this.mountPath)); - - const expectedOtp = '12345'; - server.post('/sys/mfa/validate', async (_, req) => { - const [actualOtp] = JSON.parse(req.requestBody).mfa_payload[constraintId]; - assert.true(true, 'it makes request to mfa validate endpoint'); - assert.strictEqual(actualOtp, expectedOtp, 'payload contains otp'); - }); - - // Fill in login form - await fillIn(AUTH_FORM.selectMethod, authType); - // Toggle more options to input a custom mount path - await fillInLoginFields({ ...options.loginData, path: this.mountPath }, { toggleOptions: true }); - - if (options?.hasPopupWindow) { - // fires "message" event which methods that rely on popup windows wait for - setTimeout(() => { - // set path which is used to set :mount param in the callback url => /auth/:mount/oidc/callback - window.postMessage(callbackData({ path: this.mountPath }), window.origin); - }, DELAY_IN_MS); - } - - await click(GENERAL.submitButton); - await fillIn(MFA_SELECTORS.passcode(0), expectedOtp); - await click(MFA_SELECTORS.validate); - }); - }); -} diff --git a/ui/tests/acceptance/jwt-auth-method-test.js b/ui/tests/acceptance/jwt-auth-method-test.js index 1d808c1f55..b0763537de 100644 --- a/ui/tests/acceptance/jwt-auth-method-test.js +++ b/ui/tests/acceptance/jwt-auth-method-test.js @@ -7,7 +7,6 @@ import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import { click, visit, fillIn, waitFor } from '@ember/test-helpers'; import { setupMirage } from 'ember-cli-mirage/test-support'; -import sinon from 'sinon'; import { Response } from 'miragejs'; import { ERROR_JWT_LOGIN } from 'vault/components/auth/form/oidc-jwt'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; @@ -20,7 +19,6 @@ module('Acceptance | jwt auth method', function (hooks) { hooks.beforeEach(function () { localStorage.clear(); // ensure that a token isn't stored otherwise visit('/vault/auth') will redirect to secrets - this.stub = sinon.stub(); this.server.post( '/auth/:path/oidc/auth_url', () => diff --git a/ui/tests/acceptance/oidc-auth-method-test.js b/ui/tests/acceptance/oidc-auth-method-test.js index fb97bb8620..b244b7d691 100644 --- a/ui/tests/acceptance/oidc-auth-method-test.js +++ b/ui/tests/acceptance/oidc-auth-method-test.js @@ -4,19 +4,20 @@ */ import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; -import { click, fillIn, find, visit, waitFor, waitUntil } from '@ember/test-helpers'; +import { click, fillIn, visit, waitFor } from '@ember/test-helpers'; import { logout } from 'vault/tests/helpers/auth/auth-helpers'; import { setupMirage } from 'ember-cli-mirage/test-support'; -import { callbackData, windowStub } from 'vault/tests/helpers/oidc-window-stub'; -import sinon from 'sinon'; +import { + callbackData, + DELAY_IN_MS, + triggerMessageEvent, + windowStub, +} from 'vault/tests/helpers/oidc-window-stub'; import { Response } from 'miragejs'; -import { setupTotpMfaResponse } from 'vault/tests/helpers/mfa/mfa-helpers'; import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { ERROR_MISSING_PARAMS, ERROR_WINDOW_CLOSED } from 'vault/components/auth/form/oidc-jwt'; -const DELAY_IN_MS = 500; - module('Acceptance | oidc auth method', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); @@ -66,9 +67,7 @@ module('Acceptance | oidc auth method', function (hooks) { await logout(); await this.selectMethod('oidc'); - setTimeout(() => { - window.postMessage(callbackData({ path: 'oidc' }), window.origin); - }, DELAY_IN_MS); + triggerMessageEvent('oidc'); await click(GENERAL.submitButton); }); @@ -91,9 +90,7 @@ module('Acceptance | oidc auth method', function (hooks) { return { data: { auth_url: 'http://example.com' } }; }); await visit('/vault/auth'); - setTimeout(() => { - window.postMessage(callbackData({ path: 'oidc' }), window.origin); - }, DELAY_IN_MS); + triggerMessageEvent('oidc'); await click(GENERAL.submitButton); }); @@ -103,9 +100,7 @@ module('Acceptance | oidc auth method', function (hooks) { await logout(); await this.selectMethod('oidc'); - setTimeout(() => { - window.postMessage(callbackData({ path: 'oidc' }), window.origin); - }, 500); + triggerMessageEvent('oidc'); await click(GENERAL.submitButton); await waitFor('[data-test-dashboard-card-header="Vault version"]'); @@ -157,52 +152,6 @@ module('Acceptance | oidc auth method', function (hooks) { .hasText('Authentication failed: Error fetching role: permission denied'); }); - test('it prompts mfa if configured', async function (assert) { - assert.expect(1); - - this.setupMocks(assert); - this.server.get('/auth/oidc/oidc/callback', () => setupTotpMfaResponse('foo')); - await logout(); - await this.selectMethod('oidc'); - setTimeout(() => { - window.postMessage(callbackData({ path: 'oidc' }), window.origin); - }, DELAY_IN_MS); - - await click(GENERAL.submitButton); - await waitUntil(() => find('[data-test-mfa-form]')); - assert.dom('[data-test-mfa-form]').exists('it renders TOTP MFA form'); - }); - - test('auth service is called with client_token and cluster data', async function (assert) { - const authSpy = sinon.spy(this.owner.lookup('service:auth'), 'authenticate'); - this.setupMocks(); - await logout(); - await this.selectMethod('oidc'); - setTimeout(() => { - window.postMessage(callbackData({ path: 'oidc' }), window.origin); - }, DELAY_IN_MS); - await click(GENERAL.submitButton); - const [actual] = authSpy.lastCall.args; - const expected = { - // even though this is the oidc auth method, - // the callback has returned a token at this point of the login flow - // and so the backend is 'token' - backend: 'token', - clusterId: '1', - data: { - // data from oidc/callback url - token: 'root', - }, - selectedAuth: 'oidc', - }; - - assert.propEqual( - actual, - expected, - `authenticate method called with correct args, ${JSON.stringify({ actual, expected })}` - ); - }); - // test case for https://github.com/hashicorp/vault/issues/12436 test('it should ignore messages sent from outside the app while waiting for oidc callback', async function (assert) { assert.expect(3); // one for both message events (2) and one for callback request diff --git a/ui/tests/acceptance/saml-auth-method-test.js b/ui/tests/acceptance/saml-auth-method-test.js index 9911e706aa..96288a480f 100644 --- a/ui/tests/acceptance/saml-auth-method-test.js +++ b/ui/tests/acceptance/saml-auth-method-test.js @@ -8,16 +8,12 @@ import { setupApplicationTest } from 'ember-qunit'; import { click, fillIn, find, visit, waitUntil } from '@ember/test-helpers'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { Response } from 'miragejs'; -import { windowStub } from 'vault/tests/helpers/oidc-window-stub'; -import { setupTotpMfaResponse } from 'vault/tests/helpers/mfa/mfa-helpers'; +import { DELAY_IN_MS, windowStub } from 'vault/tests/helpers/oidc-window-stub'; import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; -import { MFA_SELECTORS } from 'vault/tests/helpers/mfa/mfa-selectors'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { logout } from 'vault/tests/helpers/auth/auth-helpers'; -const DELAY_IN_MS = 500; - module('Acceptance | enterprise saml auth method', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); @@ -143,15 +139,4 @@ module('Acceptance | enterprise saml auth method', function (hooks) { await click('#logout'); assert.dom(AUTH_FORM.selectMethod).hasValue('saml', 'Previous auth method selected on logout'); }); - - test('it prompts mfa if configured', async function (assert) { - assert.expect(1); - this.server.put('/auth/saml/token', () => setupTotpMfaResponse('saml')); - - await waitUntil(() => find(AUTH_FORM.selectMethod), { timeout: DELAY_IN_MS }); - await fillIn(AUTH_FORM.selectMethod, 'saml'); - await click(GENERAL.submitButton); - await waitUntil(() => find(MFA_SELECTORS.mfaForm), { timeout: DELAY_IN_MS }); - assert.dom(MFA_SELECTORS.mfaForm).exists('it renders TOTP MFA form'); - }); }); diff --git a/ui/tests/acceptance/settings/auth/enable-test.js b/ui/tests/acceptance/settings/auth/enable-test.js index f988b5c928..88bda3a4f7 100644 --- a/ui/tests/acceptance/settings/auth/enable-test.js +++ b/ui/tests/acceptance/settings/auth/enable-test.js @@ -11,7 +11,6 @@ import { mountBackend } from 'vault/tests/helpers/components/mount-backend-form- import { login } from 'vault/tests/helpers/auth/auth-helpers'; import { deleteAuthCmd, runCmd } from 'vault/tests/helpers/commands'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; -import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; module('Acceptance | settings/auth/enable', function (hooks) { setupApplicationTest(hooks); @@ -38,7 +37,7 @@ module('Acceptance | settings/auth/enable', function (hooks) { ); await visit('/vault/access/'); - assert.dom(AUTH_FORM.linkedBlockAuth(path)).exists('mount is present in the list'); + assert.dom(GENERAL.linkedBlock(path)).exists('mount is present in the list'); // cleanup await runCmd(deleteAuthCmd(path)); diff --git a/ui/tests/acceptance/sidebar-nav-test.js b/ui/tests/acceptance/sidebar-nav-test.js index 215c314d7f..124fe92ab6 100644 --- a/ui/tests/acceptance/sidebar-nav-test.js +++ b/ui/tests/acceptance/sidebar-nav-test.js @@ -10,7 +10,7 @@ import { setupMirage } from 'ember-cli-mirage/test-support'; import { login } from 'vault/tests/helpers/auth/auth-helpers'; import modifyPassthroughResponse from 'vault/mirage/helpers/modify-passthrough-response'; import { setRunOptions } from 'ember-a11y-testing/test-support'; -import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; const link = (label) => `[data-test-sidebar-nav-link="${label}"]`; const panel = (label) => `[data-test-sidebar-nav-panel="${label}"]`; @@ -142,7 +142,7 @@ module('Acceptance | sidebar navigation', function (hooks) { await click('[data-test-auth-enable]'); assert.dom('[data-test-sidebar-nav-panel="Access"]').exists('Access nav panel renders'); await click(link('Authentication Methods')); - await click(AUTH_FORM.linkedBlockAuth('token')); + await click(GENERAL.linkedBlock('token')); await click('[data-test-configure-link]'); assert.dom('[data-test-sidebar-nav-panel="Access"]').exists('Access nav panel renders'); }); diff --git a/ui/tests/helpers/auth/auth-form-selectors.ts b/ui/tests/helpers/auth/auth-form-selectors.ts index 7d0913da2e..e3c19ce1ca 100644 --- a/ui/tests/helpers/auth/auth-form-selectors.ts +++ b/ui/tests/helpers/auth/auth-form-selectors.ts @@ -6,13 +6,9 @@ export const AUTH_FORM = { description: '[data-test-description]', form: '[data-test-auth-form]', - linkedBlockAuth: (path: string) => `[data-test-auth-backend-link="${path}"]`, selectMethod: '[data-test-select="auth type"]', tabBtn: (method: string) => `[data-test-auth-tab="${method}"] button`, // method is all lowercased tabs: '[data-test-auth-tab]', - // old form toggle, will eventually be deleted - moreOptions: '[data-test-auth-form-options-toggle]', - // new toggle, hds component is a button advancedSettings: '[data-test-auth-form-options-toggle] button', authForm: (type: string) => `[data-test-auth-form="${type}"]`, helpText: '[data-test-auth-helptext]', diff --git a/ui/tests/helpers/auth/auth-helpers.ts b/ui/tests/helpers/auth/auth-helpers.ts index 0bd0dd8550..188cfeba3b 100644 --- a/ui/tests/helpers/auth/auth-helpers.ts +++ b/ui/tests/helpers/auth/auth-helpers.ts @@ -7,7 +7,6 @@ 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 { GENERAL } from 'vault/tests/helpers/general-selectors'; -import { Server } from 'miragejs'; import type { LoginFields } from 'vault/vault/auth/form'; @@ -70,63 +69,26 @@ export const fillInLoginFields = async (loginFields: LoginFields, { toggleOption } }; -// 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' } }; - }); - }, - }, +const LOGIN_DATA = { + token: { token: 'mysupersecuretoken' }, + username: { username: 'matilda', password: 'password' }, + role: { role: 'some-dev' }, }; - -// maps auth type to request data -export const AUTH_METHOD_MAP = [ - { authType: 'token', options: LOGIN_DATA.token }, - { authType: 'github', options: LOGIN_DATA.github }, - +// maps auth type to login input data +export const AUTH_METHOD_LOGIN_DATA = { + // token methods + token: LOGIN_DATA.token, + github: LOGIN_DATA.token, // 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 }, -]; + userpass: LOGIN_DATA.username, + ldap: LOGIN_DATA.username, + okta: LOGIN_DATA.username, + radius: LOGIN_DATA.username, + // role + oidc: LOGIN_DATA.role, + jwt: LOGIN_DATA.role, + saml: LOGIN_DATA.role, +}; // Mock response for `sys/internal/ui/mounts` export const SYS_INTERNAL_UI_MOUNTS = { diff --git a/ui/tests/helpers/auth/response-stubs.ts b/ui/tests/helpers/auth/response-stubs.ts new file mode 100644 index 0000000000..f5be60d340 --- /dev/null +++ b/ui/tests/helpers/auth/response-stubs.ts @@ -0,0 +1,492 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +/* +Authentication requests return authentication information in either the "auth" or "data" key, +depending on the authentication method. + +The stubbed responses below are used to test and compare authentication logic across different method types: +- If the method has `{ mount_type: "token" }`, the authentication details are returned inside the "data" key. +- Otherwise, `mount_type` is an empty string (""), and the authentication details are found in the "auth" key. + +Some values depend on the method's mount configuration (such as `ttl` and `lease_duration`). +For consistency, all methods stubbed here were mounted using default settings. +*/ + +const BASE_REQUEST_DATA = { + request_id: 'cbca58ca-8b53-76e7-3d07-6d72f7c5affe', + lease_id: '', + renewable: false, + lease_duration: 0, + wrap_info: null, + warnings: null, +}; + +export const RESPONSE_STUBS = { + github: { + ...BASE_REQUEST_DATA, + data: null, + auth: { + client_token: 'hvs.myvaultgeneratedgithubtoken', + accessor: 'm9UFOzTtVahYxs8nQe6Su73r', + policies: ['default'], + token_policies: ['default'], + metadata: { + org: 'hashicorp', + username: 'github-user99', + }, + lease_duration: 2764800, + renewable: true, + entity_id: 'd3396007-4d0e-33f9-7e4e-beb2e87c3518', + token_type: 'service', + orphan: true, + mfa_requirement: null, + num_uses: 0, + }, + mount_type: '', + }, + jwt: { + ['lookup-self']: { + ...BASE_REQUEST_DATA, + data: { + accessor: 'MkjSR78ducuarJ6ypCDbHhBp', + creation_time: 1749659696, + creation_ttl: 2764800, + display_name: 'jwt-ugaKjSEAKwQkiGh1rbnGkp39oCSe3LQ2@clients', + entity_id: 'b6061dc8-a19e-195e-43a8-43d37f4625dd', + expire_time: '2025-07-13T12:34:56.345108-04:00', + explicit_max_ttl: 0, + id: 'hvs.myvaultgeneratedjwttoken', + issue_time: '2025-06-11T12:34:56.345113-04:00', + meta: { + role: 'reader', + }, + num_uses: 0, + orphan: true, + path: 'auth/jwt/login', + policies: ['default', 'reader'], + renewable: true, + ttl: 2764800, + type: 'service', + }, + auth: null, + mount_type: 'token', + }, + login: { + ...BASE_REQUEST_DATA, + data: null, + auth: { + client_token: 'hvs.myvaultgeneratedjwttoken', + accessor: 'wzT0PZBYP0ZWyXI0Cst3UlsY', + policies: ['default', 'reader'], + token_policies: ['default', 'reader'], + metadata: { + role: 'reader', + }, + lease_duration: 2764800, + renewable: true, + entity_id: 'b6061dc8-a19e-195e-43a8-43d37f4625dd', + token_type: 'service', + orphan: true, + mfa_requirement: null, + num_uses: 0, + }, + mount_type: '', + }, + }, + ldap: { + ...BASE_REQUEST_DATA, + data: {}, // empty object instead of null + auth: { + client_token: 'hvs.myvaultgeneratedldaptoken', + accessor: 'aIFJgy2Eo6qgIUx9bAuOKC6y', + policies: ['default'], + token_policies: ['default'], + metadata: { + username: 'bob.johnson', + }, + lease_duration: 2764800, + renewable: true, + entity_id: '998d4fb7-c7db-8d81-c34d-dc1754103510', + token_type: 'service', + orphan: true, + mfa_requirement: null, + num_uses: 0, + }, + mount_type: '', + }, + oidc: { + // Response from the OIDC token exchange (happens first) + ['oidc/callback']: { + ...BASE_REQUEST_DATA, + data: null, + auth: { + client_token: 'hvs.myvaultgeneratedoidctoken', + accessor: 'AjTM1Ec825ZJCg4xEVxbdPmf', + policies: ['default'], + token_policies: ['default'], + metadata: { + role: 'reader', + }, + lease_duration: 2764800, + renewable: true, + entity_id: '18b57edf-acff-3e65-2ff2-6c772ce44924', + token_type: 'service', + orphan: true, + mfa_requirement: null, + num_uses: 0, + }, + mount_type: '', + }, + // Response from token lookup (after OIDC token exchange) + 'lookup-self': { + ...BASE_REQUEST_DATA, + data: { + accessor: 'ew50HTqF2xgsmaKIsdKpJtTc', + creation_time: 1749584514, + creation_ttl: 2764800, + display_name: 'my-oidc-google-oauth2|105299854624506884705', + entity_id: '18b57edf-acff-3e65-2ff2-6c772ce44924', + expire_time: '2025-07-12T15:41:54.961915-04:00', + explicit_max_ttl: 0, + id: 'hvs.myvaultgeneratedoidctoken', + issue_time: '2025-06-10T15:41:54.961919-04:00', + meta: { + role: 'reader', + }, + num_uses: 0, + orphan: true, + path: 'auth/my-oidc/oidc/callback', + policies: ['default'], + renewable: true, + ttl: 2764799, + type: 'service', + }, + auth: null, + mount_type: 'token', + }, + }, + okta: { + ...BASE_REQUEST_DATA, + data: {}, // empty object instead of null + auth: { + client_token: 'hvs.myvaultgeneratedoktatoken', + accessor: 'bnCp5tEioxHJXgSXbKowYoZj', + policies: ['default'], + token_policies: ['default'], + metadata: { + policies: '', + username: 'vaultuser@gmail.com', + }, + lease_duration: 2592000, + renewable: true, + entity_id: 'cb1ed44d-d3fb-5fd4-62cf-e027f84f35f6', + token_type: 'service', + orphan: true, + mfa_requirement: null, + num_uses: 0, + }, + mount_type: '', + }, + radius: { + ...BASE_REQUEST_DATA, + data: null, + auth: { + client_token: 'hvs.myvaultgeneratedradiustoken', + accessor: 'wx4Df1iktankETDXZ67tpGgo', + policies: ['default'], + token_policies: ['default'], + metadata: { + policies: '', + username: 'vaultuser', + }, + lease_duration: 2764800, + renewable: true, + entity_id: '7056887c-7e54-3f76-e498-1f76fc0d0e2c', + token_type: 'service', + orphan: true, + mfa_requirement: null, + num_uses: 0, + }, + mount_type: '', + }, + token: { + request_id: '6a7c7b72-6f0b-1700-5a4e-cd82e768b5d8', + lease_id: '', + renewable: false, + lease_duration: 0, + data: { + accessor: '3tl0hAUwdDJVduSEnIca7Tr6', + creation_time: 1744649084, + creation_ttl: 2764800, + display_name: 'token', + entity_id: '', + expire_time: '2025-05-16T09:44:44.837733-07:00', + explicit_max_ttl: 0, + id: 'hvs.myvaultgeneratedtoken', + issue_time: '2025-04-14T09:44:44.837735-07:00', + meta: null, + num_uses: 0, + orphan: false, + path: 'auth/token/create', + policies: ['default'], + renewable: true, + ttl: 2764785, + type: 'service', + }, + wrap_info: null, + warnings: null, + auth: null, + mount_type: 'token', + }, + userpass: { + ...BASE_REQUEST_DATA, + data: null, + auth: { + client_token: 'hvs.myvaultgenerateduserpasstoken', + accessor: 'WSm7g8UzWEXhMO7g8C1zggDU', + policies: ['default'], + token_policies: ['default'], + metadata: { + username: 'bob', + }, + lease_duration: 2764800, + renewable: true, + entity_id: 'fa17f31c-41b0-c927-7b2b-d905200bb95c', + token_type: 'service', + orphan: true, + mfa_requirement: null, + num_uses: 0, + }, + mount_type: '', + }, + // ENTERPRISE ONLY + saml: { + ['saml/token']: { + ...BASE_REQUEST_DATA, + data: null, + auth: { + client_token: 'hvs.myvaultgeneratedsamltoken', + accessor: 'kHiH5wwqClnujASsKalca1T6', + policies: ['default'], + token_policies: ['default'], + metadata: { + role: 'dev', + }, + lease_duration: 1800, + renewable: true, + entity_id: '81fc10e5-49a3-d0a2-9835-ac6b551ee266', + token_type: 'service', + orphan: true, + mfa_requirement: null, + num_uses: 0, + }, + mount_type: '', + }, + ['lookup-self']: { + ...BASE_REQUEST_DATA, + data: { + accessor: 'H4fWtQaYX3aaEg1JIPSWiK9v', + creation_time: 1749585309, + creation_ttl: 1800, + display_name: 'saml-vaultuser@hashicorp.com', + entity_id: '81fc10e5-49a3-d0a2-9835-ac6b551ee266', + expire_time: '2025-06-10T16:25:09.246659-04:00', + explicit_max_ttl: 0, + id: 'hvs.myvaultgeneratedsamltoken', + issue_time: '2025-06-10T15:55:09.246666-04:00', + meta: { + role: 'dev', + }, + num_uses: 0, + orphan: true, + path: 'auth/saml/token', + policies: ['default'], + renewable: true, + ttl: 1800, + type: 'service', + }, + auth: null, + mount_type: 'token', + }, + }, +}; + +// Once the auth service authentication method is simplified and no longer handles every auth type +// the "backend" key should be completely removable +export const TOKEN_DATA = { + github: { + backend: { + description: 'GitHub authentication.', + displayNamePath: ['metadata.org', 'metadata.username'], + formAttributes: ['token'], + mountPath: 'github', + tokenPath: 'client_token', + type: 'github', + typeDisplay: 'GitHub', + }, + displayName: `${RESPONSE_STUBS.github.auth.metadata.org}/${RESPONSE_STUBS.github.auth.metadata.username}`, + entity_id: RESPONSE_STUBS.github.auth.entity_id, + policies: RESPONSE_STUBS.github.auth.policies, + renewable: RESPONSE_STUBS.github.auth.renewable, + token: RESPONSE_STUBS.github.auth.client_token, + tokenExpirationEpoch: 1752352843223, + ttl: RESPONSE_STUBS.github.auth.lease_duration, + userRootNamespace: '', + }, + ldap: { + backend: { + description: 'LDAP authentication.', + displayNamePath: 'metadata.username', + formAttributes: ['username', 'password'], + mountPath: 'ldap', + tokenPath: 'client_token', + type: 'ldap', + typeDisplay: 'LDAP', + }, + displayName: RESPONSE_STUBS.ldap.auth.metadata.username, + entity_id: RESPONSE_STUBS.ldap.auth.entity_id, + policies: RESPONSE_STUBS.ldap.auth.policies, + renewable: RESPONSE_STUBS.ldap.auth.renewable, + token: RESPONSE_STUBS.ldap.auth.client_token, + tokenExpirationEpoch: 1752352843696, + ttl: RESPONSE_STUBS.ldap.auth.lease_duration, + userRootNamespace: '', + }, + jwt: { + backend: { + description: 'Authenticate using JWT or OIDC provider.', + displayNamePath: 'display_name', + formAttributes: ['role', 'jwt'], + mountPath: 'jwt', + tokenPath: 'client_token', + type: 'jwt', + typeDisplay: 'JWT', + }, + displayName: RESPONSE_STUBS.jwt['lookup-self'].data.display_name, + entity_id: RESPONSE_STUBS.jwt['lookup-self'].data.entity_id, + policies: RESPONSE_STUBS.jwt['lookup-self'].data.policies, + renewable: RESPONSE_STUBS.jwt['lookup-self'].data.renewable, + token: RESPONSE_STUBS.jwt['lookup-self'].data.id, + tokenExpirationEpoch: 1752425319766, + ttl: RESPONSE_STUBS.jwt['lookup-self'].data.ttl, + userRootNamespace: '', + }, + oidc: { + backend: { + description: 'Token authentication.', + displayNamePath: 'display_name', + formAttributes: ['token'], + mountPath: 'oidc', + tokenPath: 'id', + type: 'token', + typeDisplay: 'Token', + }, + displayName: RESPONSE_STUBS.oidc['lookup-self'].data.display_name, + entity_id: RESPONSE_STUBS.oidc['lookup-self'].data.entity_id, + policies: RESPONSE_STUBS.oidc['lookup-self'].data.policies, + renewable: RESPONSE_STUBS.oidc['lookup-self'].data.renewable, + token: RESPONSE_STUBS.oidc['lookup-self'].data.id, + tokenExpirationEpoch: 1752349314961, + ttl: RESPONSE_STUBS.oidc['lookup-self'].data.ttl, + userRootNamespace: '', + }, + okta: { + backend: { + description: 'Authenticate with your Okta username and password.', + displayNamePath: 'metadata.username', + formAttributes: ['username', 'password'], + mountPath: 'okta', + tokenPath: 'client_token', + type: 'okta', + typeDisplay: 'Okta', + }, + displayName: RESPONSE_STUBS.okta.auth.metadata.username, + entity_id: RESPONSE_STUBS.okta.auth.entity_id, + policies: RESPONSE_STUBS.okta.auth.policies, + renewable: RESPONSE_STUBS.okta.auth.renewable, + token: RESPONSE_STUBS.okta.auth.client_token, + tokenExpirationEpoch: 1752180044950, + ttl: RESPONSE_STUBS.okta.auth.lease_duration, + userRootNamespace: '', + }, + radius: { + backend: { + description: 'Authenticate with your RADIUS username and password.', + displayNamePath: 'metadata.username', + formAttributes: ['username', 'password'], + mountPath: 'radius', + tokenPath: 'client_token', + type: 'radius', + typeDisplay: 'RADIUS', + }, + displayName: RESPONSE_STUBS.radius.auth.metadata.username, + entity_id: RESPONSE_STUBS.radius.auth.entity_id, + policies: RESPONSE_STUBS.radius.auth.policies, + renewable: RESPONSE_STUBS.radius.auth.renewable, + token: RESPONSE_STUBS.radius.auth.client_token, + tokenExpirationEpoch: 1752352846180, + ttl: RESPONSE_STUBS.radius.auth.lease_duration, + userRootNamespace: '', + }, + token: { + userRootNamespace: '', + displayName: 'token', + backend: { + mountPath: 'token', + type: 'token', + typeDisplay: 'Token', + description: 'Token authentication.', + tokenPath: 'id', + displayNamePath: 'display_name', + formAttributes: ['token'], + }, + token: RESPONSE_STUBS.token.data.id, + policies: RESPONSE_STUBS.token.data.policies, + renewable: RESPONSE_STUBS.token.data.renewable, + entity_id: RESPONSE_STUBS.token.data.entity_id, + ttl: RESPONSE_STUBS.token.data.ttl, + tokenExpirationEpoch: 1747413884837, + }, + userpass: { + backend: { + description: 'A simple username and password backend.', + displayNamePath: 'metadata.username', + formAttributes: ['username', 'password'], + mountPath: 'userpass', + tokenPath: 'client_token', + type: 'userpass', + typeDisplay: 'Username', + }, + displayName: RESPONSE_STUBS.userpass.auth.metadata.username, + entity_id: RESPONSE_STUBS.userpass.auth.entity_id, + policies: RESPONSE_STUBS.userpass.auth.policies, + renewable: RESPONSE_STUBS.userpass.auth.renewable, + token: RESPONSE_STUBS.userpass.auth.client_token, + tokenExpirationEpoch: 1752352843463, + ttl: RESPONSE_STUBS.userpass.auth.lease_duration, + userRootNamespace: '', + }, + // ENTERPRISE ONLY + saml: { + backend: { + description: 'Token authentication.', + displayNamePath: 'display_name', + formAttributes: ['token'], + mountPath: 'saml', + tokenPath: 'id', + type: 'token', + typeDisplay: 'Token', + }, + displayName: RESPONSE_STUBS.saml['lookup-self'].data.display_name, + entity_id: RESPONSE_STUBS.saml['lookup-self'].data.entity_id, + policies: RESPONSE_STUBS.saml['lookup-self'].data.policies, + renewable: RESPONSE_STUBS.saml['lookup-self'].data.renewable, + token: RESPONSE_STUBS.saml['lookup-self'].data.id, + tokenExpirationEpoch: 1749587109246, + ttl: RESPONSE_STUBS.saml['lookup-self'].data.ttl, + userRootNamespace: '', + }, +}; diff --git a/ui/tests/helpers/general-selectors.ts b/ui/tests/helpers/general-selectors.ts index 762e3e36a0..3482a0e2e0 100644 --- a/ui/tests/helpers/general-selectors.ts +++ b/ui/tests/helpers/general-selectors.ts @@ -36,6 +36,7 @@ export const GENERAL = { menuTrigger: '[data-test-popup-menu-trigger]', menuItem: (name: string) => `[data-test-popup-menu="${name}"]`, listItem: '[data-test-list-item-link]', + linkedBlock: (item: string) => `[data-test-linked-block="${item}"]`, /* ────── Inputs / Form Fields ────── */ checkboxByAttr: (attr: string) => `[data-test-checkbox="${attr}"]`, diff --git a/ui/tests/helpers/oidc-window-stub.js b/ui/tests/helpers/oidc-window-stub.js index baafb662c8..4ade61cc6f 100644 --- a/ui/tests/helpers/oidc-window-stub.js +++ b/ui/tests/helpers/oidc-window-stub.js @@ -4,6 +4,8 @@ */ import sinon from 'sinon'; +export const DELAY_IN_MS = 500; + // suggestions for a custom popup // passing { close: true } automatically closes popups opened from window.open() // passing { closed: true } sets value on popup window @@ -30,3 +32,11 @@ export const callbackData = (data = {}) => ({ code: 'code', ...data, }); + +// Simulates the OIDC popup message event the OIDC auth method waits for +// mountPath is necessary because it builds the callback URL +export const triggerMessageEvent = (mountPath, delay = DELAY_IN_MS) => { + setTimeout(() => { + window.postMessage(callbackData({ path: mountPath }), window.origin); + }, delay); +}; diff --git a/ui/tests/integration/components/auth/form-template-test.js b/ui/tests/integration/components/auth/form-template-test.js index d2228692d3..5723bf7e8e 100644 --- a/ui/tests/integration/components/auth/form-template-test.js +++ b/ui/tests/integration/components/auth/form-template-test.js @@ -10,13 +10,9 @@ 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 { AUTH_METHOD_LOGIN_DATA } 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 { ENTERPRISE_LOGIN_METHODS, supportedTypes } from 'vault/utils/supported-login-methods'; import { overrideResponse } from 'vault/tests/helpers/stubs'; import { ERROR_JWT_LOGIN } from 'vault/components/auth/form/oidc-jwt'; @@ -81,7 +77,7 @@ module('Integration | Component | auth | form template', function (hooks) { test('dropdown does not include enterprise methods on community versions', async function (assert) { this.version.type = 'community'; - const supported = BASE_LOGIN_METHODS.map((m) => m.type); + const supported = supportedTypes(false); const unsupported = ENTERPRISE_LOGIN_METHODS.map((m) => m.type); assert.expect(supported.length + unsupported.length); await this.renderComponent(); @@ -234,21 +230,23 @@ module('Integration | Component | auth | form template', function (hooks) { this.namespaceQueryParam = ''; }); - // in th ent module to test ALL supported login methods + // in the 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; + const authMethodTypes = supportedTypes(true); + const totalFields = Object.values(AUTH_METHOD_LOGIN_DATA).reduce( + (sum, obj) => sum + Object.keys(obj).length, + 0 + ); // 3 assertions per method, plus an assertion for each expected field - assert.expect(3 * methodCount + sum); // count at time of writing is 40 + assert.expect(3 * authMethodTypes.length + totalFields); // count at time of writing is 40 await this.renderComponent(); - for (const method of AUTH_METHOD_MAP) { - const { authType, options } = method; + for (const authType of authMethodTypes) { + const loginData = AUTH_METHOD_LOGIN_DATA[authType]; - const fields = Object.keys(options.loginData); + const fields = Object.keys(loginData); await fillIn(GENERAL.selectByAttr('auth type'), authType); assert.dom(GENERAL.selectByAttr('auth type')).hasValue(authType), `${authType}: it selects type`; @@ -273,7 +271,7 @@ module('Integration | Component | auth | form template', function (hooks) { }); test('dropdown includes enterprise methods', async function (assert) { - const supported = ALL_LOGIN_METHODS.map((m) => m.type); + const supported = supportedTypes(true); assert.expect(supported.length); await this.renderComponent(); diff --git a/ui/tests/integration/components/auth/form/test-helper.js b/ui/tests/integration/components/auth/form/auth-form-test-helper.js similarity index 83% rename from ui/tests/integration/components/auth/form/test-helper.js rename to ui/tests/integration/components/auth/form/auth-form-test-helper.js index 0f83c7b936..29fcf6b91b 100644 --- a/ui/tests/integration/components/auth/form/test-helper.js +++ b/ui/tests/integration/components/auth/form/auth-form-test-helper.js @@ -5,7 +5,7 @@ import { click, fillIn, findAll } 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 { AUTH_METHOD_LOGIN_DATA, fillInLoginFields } from 'vault/tests/helpers/auth/auth-helpers'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; /* @@ -51,12 +51,9 @@ export default (test, { standardSubmit = true } = {}) => { if (standardSubmit) { test('it submits form data with defaults', async function (assert) { await this.renderComponent(); - const { options } = AUTH_METHOD_MAP.find((m) => m.authType === this.authType); - const { loginData } = options; + const loginData = AUTH_METHOD_LOGIN_DATA[this.authType]; - for (const [field, value] of Object.entries(loginData)) { - await fillIn(GENERAL.inputByAttr(field), value); - } + await fillInLoginFields(loginData); await click(GENERAL.submitButton); const [actual] = this.authenticateStub.lastCall.args; assert.propEqual( @@ -70,12 +67,9 @@ export default (test, { standardSubmit = true } = {}) => { // component here just yields <:advancedSettings> to test form submits data from yielded inputs test('it submits form data from yielded inputs', async function (assert) { await this.renderComponent({ yieldBlock: true }); - const { options } = AUTH_METHOD_MAP.find((m) => m.authType === this.authType); - const { loginData } = options; + const loginData = AUTH_METHOD_LOGIN_DATA[this.authType]; - for (const [field, value] of Object.entries(loginData)) { - await fillIn(GENERAL.inputByAttr(field), value); - } + await fillInLoginFields(loginData); await fillIn(GENERAL.inputByAttr('path'), `custom-${this.authType}`); await click(GENERAL.submitButton); diff --git a/ui/tests/integration/components/auth/form/base-test.js b/ui/tests/integration/components/auth/form/base-test.js index 8341c04394..19afb39dc4 100644 --- a/ui/tests/integration/components/auth/form/base-test.js +++ b/ui/tests/integration/components/auth/form/base-test.js @@ -8,7 +8,7 @@ import { setupRenderingTest } from 'ember-qunit'; import hbs from 'htmlbars-inline-precompile'; import { click, fillIn, find, render } from '@ember/test-helpers'; import sinon from 'sinon'; -import testHelper from './test-helper'; +import testHelper from './auth-form-test-helper'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; // These auth types all use the default methods in auth/form/base diff --git a/ui/tests/integration/components/auth/form/oidc-jwt-test.js b/ui/tests/integration/components/auth/form/oidc-jwt-test.js index d71cc0fbbd..fec8e57905 100644 --- a/ui/tests/integration/components/auth/form/oidc-jwt-test.js +++ b/ui/tests/integration/components/auth/form/oidc-jwt-test.js @@ -16,7 +16,7 @@ import { overrideResponse } from 'vault/tests/helpers/stubs'; import { setupMirage } from 'ember-cli-mirage/test-support'; import * as parseURL from 'core/utils/parse-url'; import sinon from 'sinon'; -import testHelper from './test-helper'; +import testHelper from './auth-form-test-helper'; /* The OIDC and JWT mounts call the same endpoint (see docs https://developer.hashicorp.com/vault/docs/auth/jwt ) @@ -311,6 +311,7 @@ module('Integration | Component | auth | form | oidc-jwt', function (hooks) { hooks.afterEach(function () { this.authenticateStub.restore(); + this.routerStub.restore(); }); test('it renders helper text', async function (assert) { diff --git a/ui/tests/integration/components/auth/form/okta-test.js b/ui/tests/integration/components/auth/form/okta-test.js index a995a79530..f5961df013 100644 --- a/ui/tests/integration/components/auth/form/okta-test.js +++ b/ui/tests/integration/components/auth/form/okta-test.js @@ -9,8 +9,8 @@ import hbs from 'htmlbars-inline-precompile'; import { click, fillIn, render } from '@ember/test-helpers'; import sinon from 'sinon'; import { setupMirage } from 'ember-cli-mirage/test-support'; -import testHelper from './test-helper'; -import { LOGIN_DATA } from 'vault/tests/helpers/auth/auth-helpers'; +import testHelper from './auth-form-test-helper'; +import { fillInLoginFields } from 'vault/tests/helpers/auth/auth-helpers'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; import * as uuid from 'core/utils/uuid'; @@ -38,13 +38,6 @@ module('Integration | Component | auth | form | okta', function (hooks) { custom: { path: 'custom-okta', username: 'matilda', password: 'password', nonce: this.nonce }, }; - this.fillInForm = async () => { - const { loginData } = LOGIN_DATA.username; - for (const [field, value] of Object.entries(loginData)) { - await fillIn(GENERAL.inputByAttr(field), value); - } - }; - this.renderComponent = ({ yieldBlock = false } = {}) => { if (yieldBlock) { return render(hbs` @@ -85,7 +78,8 @@ module('Integration | Component | auth | form | okta', function (hooks) { }); await this.renderComponent(); - await this.fillInForm(); + await fillInLoginFields({ username: 'matilda', password: 'password' }); + await click(GENERAL.submitButton); const [actual] = this.authenticateStub.lastCall.args; assert.propEqual( @@ -106,7 +100,7 @@ module('Integration | Component | auth | form | okta', function (hooks) { }); await this.renderComponent({ yieldBlock: true }); - await this.fillInForm(); + await fillInLoginFields({ username: 'matilda', password: 'password' }); await fillIn(GENERAL.inputByAttr('path'), `custom-${this.authType}`); await click(GENERAL.submitButton); const [actual] = this.authenticateStub.lastCall.args; @@ -119,7 +113,7 @@ module('Integration | Component | auth | form | okta', function (hooks) { test('it displays okta number challenge answer', async function (assert) { await this.renderComponent(); - await this.fillInForm(); + await fillInLoginFields({ username: 'matilda', password: 'password' }); await click(GENERAL.submitButton); assert .dom('[data-test-okta-number-challenge]') @@ -130,7 +124,7 @@ module('Integration | Component | auth | form | okta', function (hooks) { test('it returns to login when "Back to login" is clicked', async function (assert) { await this.renderComponent(); - await this.fillInForm(); + await fillInLoginFields({ username: 'matilda', password: 'password' }); await click(GENERAL.submitButton); assert.dom('[data-test-okta-number-challenge]').exists(); await click(GENERAL.backButton); @@ -160,14 +154,14 @@ module('Integration | Component | auth | form | okta', function (hooks) { }); await this.renderComponent(); - await this.fillInForm(); + await fillInLoginFields({ username: 'matilda', password: 'password' }); await click(GENERAL.submitButton); }); test('it renders error message when okta verify request errors', async function (assert) { this.server.get(`/auth/okta/verify/${this.nonce}`, () => new Response(500)); await this.renderComponent(); - await this.fillInForm(); + await fillInLoginFields({ username: 'matilda', password: 'password' }); await click(GENERAL.submitButton); assert.dom(GENERAL.messageError).hasText('Error An error occurred, please try again'); }); diff --git a/ui/tests/integration/components/auth/form/saml-test.js b/ui/tests/integration/components/auth/form/saml-test.js index f1f5be111b..2b2c527cd8 100644 --- a/ui/tests/integration/components/auth/form/saml-test.js +++ b/ui/tests/integration/components/auth/form/saml-test.js @@ -70,7 +70,7 @@ module('Integration | Component | auth | form | saml', function (hooks) { hooks.afterEach(function () { this.windowStub.restore(); - sinon.restore(); + this.authenticateStub.restore(); }); test('it renders helper text', async function (assert) { diff --git a/ui/tests/integration/components/auth/page-test.js b/ui/tests/integration/components/auth/page-test.js deleted file mode 100644 index de772efe23..0000000000 --- a/ui/tests/integration/components/auth/page-test.js +++ /dev/null @@ -1,708 +0,0 @@ -/** - * 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, SYS_INTERNAL_UI_MOUNTS } from 'vault/tests/helpers/auth/auth-helpers'; -import { GENERAL } from 'vault/tests/helpers/general-selectors'; -import { CSP_ERROR } from 'vault/components/auth/page'; -import { setupTotpMfaResponse } from 'vault/tests/helpers/mfa/mfa-helpers'; - -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.directLinkData = null; - this.loginSettings = null; - this.namespaceQueryParam = ''; - this.oidcProviderQueryParam = ''; - this.onAuthSuccess = sinon.spy(); - this.onNamespaceUpdate = sinon.spy(); - this.visibleAuthMounts = false; - - this.renderComponent = () => { - return render(hbs` - - `); - }; - - // 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 and disables namespace input when oidc provider query param is present', async function (assert) { - this.oidcProviderQueryParam = 'myprovider'; - this.version.features = ['Namespaces']; - await this.renderComponent(); - assert.dom(AUTH_FORM.logo).exists(); - assert.dom(GENERAL.inputByAttr('namespace')).isDisabled(); - 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 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 passes query param to namespace input', async function (assert) { - this.version.features = ['Namespaces']; - this.namespaceQueryParam = 'ns-1'; - await this.renderComponent(); - assert.dom(GENERAL.inputByAttr('namespace')).hasValue(this.namespaceQueryParam); - }); - - test('it does not render the namespace input on community', async function (assert) { - this.version.type = 'community'; - this.version.features = []; - await this.renderComponent(); - assert.dom(GENERAL.inputByAttr('namespace')).doesNotExist(); - }); - - test('it does not render the namespace input on enterprise without the "Namespaces" feature', async function (assert) { - this.version.type = 'enterprise'; - this.version.features = []; - await this.renderComponent(); - assert.dom(GENERAL.inputByAttr('namespace')).doesNotExist(); - }); - - test('it selects type in the dropdown if direct link just has type', async function (assert) { - this.directLinkData = { type: 'oidc' }; - await this.renderComponent(); - assert.dom(AUTH_FORM.tabBtn('oidc')).doesNotExist('tab does not render'); - assert.dom(GENERAL.selectByAttr('auth type')).hasValue('oidc', 'dropdown has type selected'); - assert.dom(AUTH_FORM.authForm('oidc')).exists(); - assert.dom(GENERAL.inputByAttr('role')).exists(); - await click(AUTH_FORM.advancedSettings); - assert.dom(GENERAL.inputByAttr('path')).exists({ count: 1 }); - assert.dom(GENERAL.backButton).doesNotExist(); - assert - .dom(GENERAL.button('Sign in with other methods')) - .doesNotExist('"Sign in with other methods" does not render'); - }); - - module('listing visibility', function (hooks) { - hooks.beforeEach(function () { - this.visibleAuthMounts = SYS_INTERNAL_UI_MOUNTS; - window.localStorage.clear(); - }); - - test('it formats and renders tabs if visible auth mounts exist', async function (assert) { - await this.renderComponent(); - const expectedTabs = [ - { type: 'userpass', display: 'Userpass' }, - { type: 'oidc', display: 'OIDC' }, - { type: 'ldap', display: 'LDAP' }, - ]; - - 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 renders dropdown as alternate view', 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(GENERAL.button('Sign in with other methods')); - assert - .dom(GENERAL.button('Sign in with other methods')) - .doesNotExist('button disappears after it is clicked'); - assert.dom(GENERAL.selectByAttr('auth type')).hasValue('userpass', 'dropdown has userpass selected'); - assert.dom(AUTH_FORM.advancedSettings).exists('toggle renders even though userpass has visible mounts'); - await click(AUTH_FORM.advancedSettings); - assert.dom(GENERAL.inputByAttr('path')).exists({ count: 1 }); - assert.dom(GENERAL.inputByAttr('path')).hasValue('', 'it renders empty custom path input'); - - await fillIn(GENERAL.selectByAttr('auth type'), 'oidc'); - assert.dom(AUTH_FORM.advancedSettings).exists('toggle renders even though oidc has a visible mount'); - await click(AUTH_FORM.advancedSettings); - assert.dom(GENERAL.inputByAttr('path')).exists({ count: 1 }); - assert.dom(GENERAL.inputByAttr('path')).hasValue('', 'it renders empty custom path input'); - 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(GENERAL.button('Sign in with other methods')) - .exists('"Sign in with other methods" renders again'); - }); - - module('with a direct link', function (hooks) { - hooks.beforeEach(function () { - // if path exists, the mount has listing_visibility="unauth" - this.directLinkIsVisibleMount = { path: 'my-oidc/', type: 'oidc' }; - this.directLinkIsJustType = { type: 'okta' }; - }); - - test('it selects type in the dropdown if direct link is just type', async function (assert) { - this.directLinkData = this.directLinkIsJustType; - await this.renderComponent(); - assert.dom(AUTH_FORM.tabBtn('okta')).doesNotExist('tab does not render'); - assert.dom(GENERAL.selectByAttr('auth type')).hasValue('okta', 'dropdown has type selected'); - assert.dom(AUTH_FORM.authForm('okta')).exists(); - assert.dom(GENERAL.inputByAttr('username')).exists(); - assert.dom(GENERAL.inputByAttr('password')).exists(); - await click(AUTH_FORM.advancedSettings); - assert.dom(GENERAL.inputByAttr('path')).exists({ count: 1 }); - assert - .dom(GENERAL.button('Sign in with other methods')) - .doesNotExist('"Sign in with other methods" does not render'); - assert.dom(GENERAL.backButton).exists('back button renders because tabs exist for other methods'); - await click(GENERAL.backButton); - assert - .dom(AUTH_FORM.tabBtn('userpass')) - .hasAttribute('aria-selected', 'true', 'first tab is selected on back'); - }); - - test('it renders single method view instead of tabs if direct link includes path', async function (assert) { - this.directLinkData = this.directLinkIsVisibleMount; - await this.renderComponent(); - assert.dom(AUTH_FORM.authForm('oidc')).exists; - assert.dom(AUTH_FORM.tabBtn('oidc')).hasText('OIDC', 'it renders tab for type'); - assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'only one tab renders'); - assert.dom(GENERAL.inputByAttr('role')).exists(); - assert.dom(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden'); - assert.dom(GENERAL.inputByAttr('path')).hasValue('my-oidc/'); - assert - .dom(GENERAL.button('Sign in with other methods')) - .exists('"Sign in with other methods" renders'); - assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist(); - assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); - assert.dom(GENERAL.backButton).doesNotExist(); - }); - - test('it prioritizes auth type from canceled mfa instead of direct link for path', async function (assert) { - assert.expect(1); - this.directLinkData = this.directLinkIsVisibleMount; - const authType = 'okta'; - const { loginData, url } = REQUEST_DATA.username; - const requestUrl = url({ path: authType, username: loginData?.username }); - this.server.post(requestUrl, () => setupTotpMfaResponse(authType)); - - await this.renderComponent(); - await click(GENERAL.button('Sign in with other methods')); - await fillIn(AUTH_FORM.selectMethod, authType); - await fillInLoginFields(loginData); - await click(GENERAL.submitButton); - await waitFor('[data-test-mfa-description]'); // wait until MFA validation renders - await click(GENERAL.backButton); - assert.dom(AUTH_FORM.selectMethod).hasValue(authType, 'Okta is selected in dropdown'); - }); - - test('it prioritizes auth type from canceled mfa instead of direct link with type', async function (assert) { - assert.expect(1); - this.directLinkData = this.directLinkIsJustType; - const authType = 'userpass'; - const { loginData, url } = REQUEST_DATA.username; - const requestUrl = url({ path: authType, username: loginData?.username }); - this.server.post(requestUrl, () => setupTotpMfaResponse(authType)); - await this.renderComponent(); - await fillIn(AUTH_FORM.selectMethod, authType); - await fillInLoginFields(loginData); - await click(GENERAL.submitButton); - await click(GENERAL.backButton); - assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true'); - }); - }); - }); - - 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 fillInLoginFields(loginData); - await click(GENERAL.submitButton); - 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) { - 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(GENERAL.submitButton); - - 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('it preselects auth type from canceled mfa', async function (assert) { - assert.expect(1); - const { loginData, url } = options; - const requestUrl = url({ path: authType, username: loginData?.username }); - this.server.post(requestUrl, () => setupTotpMfaResponse(authType)); - - await this.renderComponent(); - await fillIn(AUTH_FORM.selectMethod, authType); - await fillInLoginFields(loginData); - await click(GENERAL.submitButton); - await click(GENERAL.backButton); - assert.dom(AUTH_FORM.selectMethod).hasValue(authType, `${authType} is selected in dropdown`); - }); - } - - // 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(GENERAL.submitButton); - 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)}`); - }); - - /* - Login settings are an enterprise only feature but the component is version agnostic (and subsequently so are these tests) - because fetching login settings happens in the route only for enterprise versions. - Each combination must be tested with and without visible mounts (i.e. tuned with listing_visibility="unauth") - 1. default+backups: default type set, backup types set - 2. default only: no backup types - 3. backup only: backup types set without a default - */ - module('ent login settings', function (hooks) { - hooks.beforeEach(function () { - this.loginSettings = { - defaultType: 'oidc', - backupTypes: ['userpass', 'ldap'], - }; - - this.assertPathInput = async (assert, { isHidden = false, value = '' } = {}) => { - // the path input can render behind the "Advanced settings" toggle or as a hidden input. - // Assert it only renders once and is the expected input - if (!isHidden) { - await click(AUTH_FORM.advancedSettings); - assert.dom(GENERAL.inputByAttr('path')).exists('it renders mount path input'); - } - if (isHidden) { - assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); - assert.dom(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden'); - assert.dom(GENERAL.inputByAttr('path')).hasValue(value); - } - assert.dom(GENERAL.inputByAttr('path')).exists({ count: 1 }); - }; - }); - - test('(default+backups): it initially renders default type and toggles to view backup methods', async function (assert) { - await this.renderComponent(); - assert.dom(AUTH_FORM.tabBtn('oidc')).hasText('OIDC', 'it renders default method'); - assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'only one tab renders'); - assert.dom(AUTH_FORM.authForm('oidc')).exists(); - assert.dom(GENERAL.backButton).doesNotExist(); - await this.assertPathInput(assert); - await click(GENERAL.button('Sign in with other methods')); - assert.dom(GENERAL.backButton).exists(); - assert.dom(AUTH_FORM.tabs).exists({ count: 2 }, 'it renders 2 backup type tabs'); - assert - .dom(AUTH_FORM.tabBtn('userpass')) - .hasAttribute('aria-selected', 'true', 'it selects the first backup type'); - await this.assertPathInput(assert); - await click(AUTH_FORM.tabBtn('ldap')); - assert.dom(AUTH_FORM.tabBtn('ldap')).hasAttribute('aria-selected', 'true', 'it selects ldap tab'); - await this.assertPathInput(assert); - }); - - test('(default+backups): it initially renders default type if backup types include the default method', async function (assert) { - this.loginSettings = { - defaultType: 'userpass', - backupTypes: ['ldap', 'userpass', 'oidc'], - }; - await this.renderComponent(); - assert.dom(GENERAL.backButton).doesNotExist('it renders default view'); - assert.dom(AUTH_FORM.tabBtn('userpass')).hasText('Userpass', 'it renders default method'); - assert - .dom(AUTH_FORM.tabs) - .exists({ count: 1 }, 'it is rendering the default view because only one tab renders'); - await click(GENERAL.button('Sign in with other methods')); - assert.dom(GENERAL.backButton).exists('it toggles to backup method view'); - assert.dom(AUTH_FORM.tabs).exists({ count: 3 }, 'it renders 3 backup type tabs'); - assert - .dom(AUTH_FORM.tabBtn('ldap')) - .hasAttribute('aria-selected', 'true', 'it selects the first backup type'); - }); - - test('(default only): it renders default type without backup methods', async function (assert) { - this.loginSettings.backupTypes = null; - await this.renderComponent(); - assert.dom(AUTH_FORM.tabBtn('oidc')).hasText('OIDC', 'it renders default method'); - assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'only one tab renders'); - assert.dom(GENERAL.backButton).doesNotExist(); - assert.dom(GENERAL.button('Sign in with other methods')).doesNotExist(); - }); - - test('(backups only): it initially renders backup types if no default is set', async function (assert) { - this.loginSettings.defaultType = ''; - await this.renderComponent(); - assert.dom(AUTH_FORM.tabBtn('oidc')).doesNotExist(); - assert.dom(AUTH_FORM.tabs).exists({ count: 2 }, 'it renders 2 backup type tabs'); - assert - .dom(AUTH_FORM.tabBtn('userpass')) - .hasAttribute('aria-selected', 'true', 'it selects the first backup type'); - await this.assertPathInput(assert); - assert.dom(GENERAL.backButton).doesNotExist(); - assert.dom(GENERAL.button('Sign in with other methods')).doesNotExist(); - }); - - module('all methods have visible mounts', function (hooks) { - hooks.beforeEach(function () { - this.loginSettings = { - defaultType: 'oidc', - backupTypes: ['userpass', 'ldap'], - }; - this.visibleAuthMounts = SYS_INTERNAL_UI_MOUNTS; - }); - - test('(default+backups): it hides advanced settings for both views', async function (assert) { - await this.renderComponent(); - assert.dom(AUTH_FORM.tabBtn('oidc')).hasText('OIDC', 'it renders default method'); - assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'only one tab renders'); - this.assertPathInput(assert, { isHidden: true, value: 'my-oidc/' }); - await click(GENERAL.button('Sign in with other methods')); - assert.dom(AUTH_FORM.tabs).exists({ count: 2 }, 'it renders 2 backup type tabs'); - assert - .dom(AUTH_FORM.tabBtn('userpass')) - .hasAttribute('aria-selected', 'true', 'it selects the first backup type'); - assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); - assert.dom(GENERAL.inputByAttr('path')).doesNotExist(); - assert.dom(GENERAL.selectByAttr('path')).exists(); // dropdown renders because userpass has 2 mount paths - await click(AUTH_FORM.tabBtn('ldap')); - this.assertPathInput(assert, { isHidden: true, value: 'ldap/' }); - }); - - test('(default only): it hides advanced settings and renders hidden input', async function (assert) { - this.loginSettings.backupTypes = null; - await this.renderComponent(); - assert.dom(AUTH_FORM.tabBtn('oidc')).hasText('OIDC', 'it renders default method'); - assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'only one tab renders'); - assert.dom(AUTH_FORM.authForm('oidc')).exists(); - this.assertPathInput(assert, { isHidden: true, value: 'my-oidc/' }); - assert.dom(GENERAL.backButton).doesNotExist(); - assert.dom(GENERAL.button('Sign in with other methods')).doesNotExist(); - }); - - test('(backups only): it hides advanced settings and renders hidden input', async function (assert) { - this.loginSettings.defaultType = ''; - await this.renderComponent(); - assert.dom(AUTH_FORM.tabs).exists({ count: 2 }, 'it renders 2 backup type tabs'); - assert - .dom(AUTH_FORM.tabBtn('userpass')) - .hasAttribute('aria-selected', 'true', 'it selects the first backup type'); - assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); - assert.dom(GENERAL.backButton).doesNotExist(); - assert.dom(GENERAL.button('Sign in with other methods')).doesNotExist(); - }); - }); - - module('only some methods have visible mounts', function (hooks) { - hooks.beforeEach(function () { - this.loginSettings = { - defaultType: 'oidc', - backupTypes: ['userpass', 'ldap'], - }; - this.mountData = (path) => ({ [path]: SYS_INTERNAL_UI_MOUNTS[path] }); - }); - - test('(default+backups): it hides advanced settings for default with visible mount but it renders for backups', async function (assert) { - this.visibleAuthMounts = { ...this.mountData('my-oidc/') }; - await this.renderComponent(); - this.assertPathInput(assert, { isHidden: true, value: 'my-oidc/' }); - await click(GENERAL.button('Sign in with other methods')); - assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true'); - await this.assertPathInput(assert); - await click(AUTH_FORM.tabBtn('ldap')); - await this.assertPathInput(assert); - }); - - test('(default+backups): it only renders advanced settings for method without mounts', async function (assert) { - // default and only one backup method have visible mounts - this.visibleAuthMounts = { - ...this.mountData('my-oidc/'), - ...this.mountData('userpass/'), - ...this.mountData('userpass2/'), - }; - await this.renderComponent(); - assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); - await click(GENERAL.button('Sign in with other methods')); - assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true'); - assert.dom(GENERAL.selectByAttr('path')).exists(); - assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); - await click(AUTH_FORM.tabBtn('ldap')); - assert.dom(AUTH_FORM.advancedSettings).exists(); - }); - - test('(default+backups): it hides advanced settings for single backup method with mounts', async function (assert) { - this.visibleAuthMounts = { ...this.mountData('ldap/') }; - await this.renderComponent(); - assert.dom(AUTH_FORM.authForm('oidc')).exists(); - assert.dom(AUTH_FORM.advancedSettings).exists(); - await click(GENERAL.button('Sign in with other methods')); - assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true'); - assert.dom(AUTH_FORM.advancedSettings).exists(); - await click(AUTH_FORM.tabBtn('ldap')); - this.assertPathInput(assert, { isHidden: true, value: 'ldap/' }); - }); - - test('(backups only): it hides advanced settings for single method with mounts', async function (assert) { - this.loginSettings.defaultType = ''; - this.visibleAuthMounts = { ...this.mountData('ldap/') }; - await this.renderComponent(); - assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true'); - assert.dom(AUTH_FORM.advancedSettings).exists(); - await click(AUTH_FORM.tabBtn('ldap')); - this.assertPathInput(assert, { isHidden: true, value: 'ldap/' }); - }); - }); - - module('@directLinkData overrides login settings', function (hooks) { - hooks.beforeEach(function () { - this.mountData = SYS_INTERNAL_UI_MOUNTS; - }); - - module('when there are no visible mounts at all', function (hooks) { - hooks.beforeEach(function () { - this.visibleAuthMounts = null; - this.directLinkData = { type: 'okta' }; - }); - - const testHelper = (assert) => { - assert.dom(AUTH_FORM.selectMethod).hasValue('okta'); - assert.dom(AUTH_FORM.authForm('okta')).exists(); - assert.dom(GENERAL.button('Sign in with other methods')).doesNotExist(); - assert.dom(GENERAL.backButton).doesNotExist(); - }; - - test('(default+backups): it renders standard view and selects @directLinkData type from dropdown', async function (assert) { - await this.renderComponent(); - testHelper(assert); - }); - - test('(default only): it renders standard view and selects @directLinkData type from dropdown', async function (assert) { - this.loginSettings.backupTypes = null; - await this.renderComponent(); - testHelper(assert); - }); - - test('(backups only): it renders standard view and selects @directLinkData type from dropdown', async function (assert) { - this.loginSettings.defaultType = ''; - await this.renderComponent(); - testHelper(assert); - }); - }); - - module('when param matches a visible mount path and other visible mounts exist', function (hooks) { - hooks.beforeEach(function () { - this.visibleAuthMounts = { - ...this.mountData, - 'my-okta/': { - description: '', - options: null, - type: 'okta', - }, - }; - this.directLinkData = { path: 'my-okta/', type: 'okta' }; - }); - - const testHelper = async (assert) => { - assert.dom(AUTH_FORM.tabBtn('okta')).hasText('Okta', 'it renders preferred method'); - assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'only one tab renders'); - assert.dom(AUTH_FORM.authForm('okta')); - assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); - assert.dom(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden'); - assert.dom(GENERAL.inputByAttr('path')).hasValue('my-okta/'); - assert.dom(GENERAL.inputByAttr('path')).exists({ count: 1 }); - await click(GENERAL.button('Sign in with other methods')); - assert - .dom(GENERAL.selectByAttr('auth type')) - .exists('it renders dropdown after clicking "Sign in with other"'); - }; - - test('(default+backups): it renders single mount view for @directLinkData', async function (assert) { - await this.renderComponent(); - await testHelper(assert); - }); - - test('(default only): it renders single mount view for @directLinkData', async function (assert) { - this.loginSettings.backupTypes = null; - await this.renderComponent(); - await testHelper(assert); - }); - - test('(backups only): it renders single mount view for @directLinkData', async function (assert) { - this.loginSettings.defaultType = ''; - await this.renderComponent(); - await testHelper(assert); - }); - }); - - module('when param matches a type and other visible mounts exist', function (hooks) { - hooks.beforeEach(function () { - // only type is present in directLinkData because the query param does not match a path with listing_visibility="unauth" - this.directLinkData = { type: 'okta' }; - this.visibleAuthMounts = this.mountData; - }); - - const testHelper = async (assert) => { - assert.dom(GENERAL.backButton).exists('back button renders because other methods have tabs'); - assert.dom(AUTH_FORM.selectMethod).hasValue('okta'); - assert.dom(AUTH_FORM.authForm('okta')).exists(); - assert.dom(GENERAL.button('Sign in with other methods')).doesNotExist(); - await click(GENERAL.backButton); - assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true'); - await click(GENERAL.button('Sign in with other methods')); - assert.dom(AUTH_FORM.selectMethod).exists('it toggles back to dropdown'); - }; - - test('(default+backups): it selects @directLinkData type from dropdown and toggles to tab view', async function (assert) { - await this.renderComponent(); - await testHelper(assert); - }); - - test('(default only): it selects @directLinkData type from dropdown and toggles to tab view', async function (assert) { - this.loginSettings.backupTypes = null; - await this.renderComponent(); - await testHelper(assert); - }); - - test('(backups only): it selects @directLinkData type from dropdown and toggles to tab view', async function (assert) { - this.loginSettings.defaultType = ''; - await this.renderComponent(); - await testHelper(assert); - }); - }); - - module('when param matches a type that matches other visible mounts', function (hooks) { - hooks.beforeEach(function () { - // only type exists because the query param does not match a path with listing_visibility="unauth" - this.directLinkData = { type: 'oidc' }; - this.visibleAuthMounts = this.mountData; - }); - - const testHelper = async (assert) => { - assert.dom(AUTH_FORM.tabBtn('oidc')).hasAttribute('aria-selected', 'true'); - assert.dom(AUTH_FORM.authForm('oidc')).exists(); - assert.dom(GENERAL.backButton).doesNotExist(); - await click(GENERAL.button('Sign in with other methods')); - assert.dom(AUTH_FORM.selectMethod).exists('it toggles to view dropdown'); - await click(GENERAL.backButton); - assert.dom(AUTH_FORM.tabs).exists('it toggles back to tabs'); - }; - - test('(default+backups): it selects @directLinkData type tab and toggles to dropdown view', async function (assert) { - await this.renderComponent(); - await testHelper(assert); - }); - - test('(default only): it selects @directLinkData type tab and toggles to dropdown view', async function (assert) { - this.loginSettings.backupTypes = null; - await this.renderComponent(); - await testHelper(assert); - }); - - test('(backups only): it selects @directLinkData type tab and toggles to dropdown view', async function (assert) { - this.loginSettings.defaultType = ''; - await this.renderComponent(); - await testHelper(assert); - }); - }); - }); - }); -}); diff --git a/ui/tests/integration/components/auth/page/listing-visibility-test.js b/ui/tests/integration/components/auth/page/listing-visibility-test.js new file mode 100644 index 0000000000..0d9576a395 --- /dev/null +++ b/ui/tests/integration/components/auth/page/listing-visibility-test.js @@ -0,0 +1,144 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; +import { click, fillIn, waitFor } from '@ember/test-helpers'; +import { fillInLoginFields, SYS_INTERNAL_UI_MOUNTS } from 'vault/tests/helpers/auth/auth-helpers'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import { module, test } from 'qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupTotpMfaResponse } from 'vault/tests/helpers/mfa/mfa-helpers'; +import setupTestContext from './setup-test-context'; + +module('Integration | Component | auth | page | listing visibility', function (hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function () { + setupTestContext(this); + this.visibleAuthMounts = SYS_INTERNAL_UI_MOUNTS; + }); + + test('it formats and renders tabs if visible auth mounts exist', async function (assert) { + await this.renderComponent(); + const expectedTabs = [ + { type: 'userpass', display: 'Userpass' }, + { type: 'oidc', display: 'OIDC' }, + { type: 'ldap', display: 'LDAP' }, + ]; + + 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 renders dropdown as alternate view', 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(GENERAL.button('Sign in with other methods')); + assert + .dom(GENERAL.button('Sign in with other methods')) + .doesNotExist('button disappears after it is clicked'); + assert.dom(GENERAL.selectByAttr('auth type')).hasValue('userpass', 'dropdown has userpass selected'); + assert.dom(AUTH_FORM.advancedSettings).exists('toggle renders even though userpass has visible mounts'); + await click(AUTH_FORM.advancedSettings); + assert.dom(GENERAL.inputByAttr('path')).exists({ count: 1 }); + assert.dom(GENERAL.inputByAttr('path')).hasValue('', 'it renders empty custom path input'); + + await fillIn(GENERAL.selectByAttr('auth type'), 'oidc'); + assert.dom(AUTH_FORM.advancedSettings).exists('toggle renders even though oidc has a visible mount'); + await click(AUTH_FORM.advancedSettings); + assert.dom(GENERAL.inputByAttr('path')).exists({ count: 1 }); + assert.dom(GENERAL.inputByAttr('path')).hasValue('', 'it renders empty custom path input'); + 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(GENERAL.button('Sign in with other methods')) + .exists('"Sign in with other methods" renders again'); + }); + + // integration tests for ?with= query param + module('with a direct link', function (hooks) { + hooks.beforeEach(function () { + // if path exists, the mount has listing_visibility="unauth" + this.directLinkIsVisibleMount = { path: 'my-oidc/', type: 'oidc' }; + this.directLinkIsJustType = { type: 'okta' }; + }); + + test('it selects type in the dropdown if direct link is just type', async function (assert) { + this.directLinkData = this.directLinkIsJustType; + await this.renderComponent(); + assert.dom(AUTH_FORM.tabBtn('okta')).doesNotExist('tab does not render'); + assert.dom(GENERAL.selectByAttr('auth type')).hasValue('okta', 'dropdown has type selected'); + assert.dom(AUTH_FORM.authForm('okta')).exists(); + assert.dom(GENERAL.inputByAttr('username')).exists(); + assert.dom(GENERAL.inputByAttr('password')).exists(); + await click(AUTH_FORM.advancedSettings); + assert.dom(GENERAL.inputByAttr('path')).exists({ count: 1 }); + assert + .dom(GENERAL.button('Sign in with other methods')) + .doesNotExist('"Sign in with other methods" does not render'); + assert.dom(GENERAL.backButton).exists('back button renders because tabs exist for other methods'); + await click(GENERAL.backButton); + assert + .dom(AUTH_FORM.tabBtn('userpass')) + .hasAttribute('aria-selected', 'true', 'first tab is selected on back'); + }); + + test('it renders single method view instead of tabs if direct link includes path', async function (assert) { + this.directLinkData = this.directLinkIsVisibleMount; + await this.renderComponent(); + assert.dom(AUTH_FORM.authForm('oidc')).exists; + assert.dom(AUTH_FORM.tabBtn('oidc')).hasText('OIDC', 'it renders tab for type'); + assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'only one tab renders'); + assert.dom(GENERAL.inputByAttr('role')).exists(); + assert.dom(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden'); + assert.dom(GENERAL.inputByAttr('path')).hasValue('my-oidc/'); + assert.dom(GENERAL.button('Sign in with other methods')).exists('"Sign in with other methods" renders'); + assert.dom(GENERAL.selectByAttr('auth type')).doesNotExist(); + assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); + assert.dom(GENERAL.backButton).doesNotExist(); + }); + + test('it prioritizes auth type from canceled mfa instead of direct link for path', async function (assert) { + assert.expect(1); + this.directLinkData = this.directLinkIsVisibleMount; + const authType = 'okta'; + this.server.post(`/auth/okta/login/matilda`, () => setupTotpMfaResponse(authType)); + await this.renderComponent(); + await click(GENERAL.button('Sign in with other methods')); + await fillIn(AUTH_FORM.selectMethod, authType); + await fillInLoginFields({ username: 'matilda', password: 'password' }); + await click(GENERAL.submitButton); + await waitFor('[data-test-mfa-description]'); // wait until MFA validation renders + await click(GENERAL.backButton); + assert.dom(AUTH_FORM.selectMethod).hasValue(authType, 'Okta is selected in dropdown'); + }); + + test('it prioritizes auth type from canceled mfa instead of direct link with type', async function (assert) { + assert.expect(1); + this.directLinkData = this.directLinkIsJustType; + const authType = 'userpass'; + this.server.post(`/auth/okta/login/matilda`, () => setupTotpMfaResponse(authType)); + await this.renderComponent(); + await fillIn(AUTH_FORM.selectMethod, authType); + await fillInLoginFields({ username: 'matilda', password: 'password' }); + await click(GENERAL.submitButton); + await click(GENERAL.backButton); + assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true'); + }); + }); +}); diff --git a/ui/tests/integration/components/auth/page/login-settings-test.js b/ui/tests/integration/components/auth/page/login-settings-test.js new file mode 100644 index 0000000000..8a0c6c9ab6 --- /dev/null +++ b/ui/tests/integration/components/auth/page/login-settings-test.js @@ -0,0 +1,370 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; +import { click } from '@ember/test-helpers'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { SYS_INTERNAL_UI_MOUNTS } from 'vault/tests/helpers/auth/auth-helpers'; +import setupTestContext from './setup-test-context'; + +/* + Login settings are an enterprise only feature but the component is version agnostic (and subsequently so are these tests) + because fetching login settings happens in the route only for enterprise versions. + Each combination must be tested with and without visible mounts (i.e. tuned with listing_visibility="unauth") + 1. default+backups: default type set, backup types set + 2. default only: no backup types + 3. backup only: backup types set without a default + */ +module('Integration | Component | auth | page | ent login settings', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + setupTestContext(this); + this.loginSettings = { + defaultType: 'oidc', + backupTypes: ['userpass', 'ldap'], + }; + + this.assertPathInput = async (assert, { isHidden = false, value = '' } = {}) => { + // the path input can render behind the "Advanced settings" toggle or as a hidden input. + // Assert it only renders once and is the expected input + if (!isHidden) { + await click(AUTH_FORM.advancedSettings); + assert.dom(GENERAL.inputByAttr('path')).exists('it renders mount path input'); + } + if (isHidden) { + assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); + assert.dom(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden'); + assert.dom(GENERAL.inputByAttr('path')).hasValue(value); + } + assert.dom(GENERAL.inputByAttr('path')).exists({ count: 1 }); + }; + }); + + test('(default+backups): it initially renders default type and toggles to view backup methods', async function (assert) { + await this.renderComponent(); + assert.dom(AUTH_FORM.tabBtn('oidc')).hasText('OIDC', 'it renders default method'); + assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'only one tab renders'); + assert.dom(AUTH_FORM.authForm('oidc')).exists(); + assert.dom(GENERAL.backButton).doesNotExist(); + await this.assertPathInput(assert); + await click(GENERAL.button('Sign in with other methods')); + assert.dom(GENERAL.backButton).exists(); + assert.dom(AUTH_FORM.tabs).exists({ count: 2 }, 'it renders 2 backup type tabs'); + assert + .dom(AUTH_FORM.tabBtn('userpass')) + .hasAttribute('aria-selected', 'true', 'it selects the first backup type'); + await this.assertPathInput(assert); + await click(AUTH_FORM.tabBtn('ldap')); + assert.dom(AUTH_FORM.tabBtn('ldap')).hasAttribute('aria-selected', 'true', 'it selects ldap tab'); + await this.assertPathInput(assert); + }); + + test('(default+backups): it initially renders default type if backup types include the default method', async function (assert) { + this.loginSettings = { + defaultType: 'userpass', + backupTypes: ['ldap', 'userpass', 'oidc'], + }; + await this.renderComponent(); + assert.dom(GENERAL.backButton).doesNotExist('it renders default view'); + assert.dom(AUTH_FORM.tabBtn('userpass')).hasText('Userpass', 'it renders default method'); + assert + .dom(AUTH_FORM.tabs) + .exists({ count: 1 }, 'it is rendering the default view because only one tab renders'); + await click(GENERAL.button('Sign in with other methods')); + assert.dom(GENERAL.backButton).exists('it toggles to backup method view'); + assert.dom(AUTH_FORM.tabs).exists({ count: 3 }, 'it renders 3 backup type tabs'); + assert + .dom(AUTH_FORM.tabBtn('ldap')) + .hasAttribute('aria-selected', 'true', 'it selects the first backup type'); + }); + + test('(default only): it renders default type without backup methods', async function (assert) { + this.loginSettings.backupTypes = null; + await this.renderComponent(); + assert.dom(AUTH_FORM.tabBtn('oidc')).hasText('OIDC', 'it renders default method'); + assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'only one tab renders'); + assert.dom(GENERAL.backButton).doesNotExist(); + assert.dom(GENERAL.button('Sign in with other methods')).doesNotExist(); + }); + + test('(backups only): it initially renders backup types if no default is set', async function (assert) { + this.loginSettings.defaultType = ''; + await this.renderComponent(); + assert.dom(AUTH_FORM.tabBtn('oidc')).doesNotExist(); + assert.dom(AUTH_FORM.tabs).exists({ count: 2 }, 'it renders 2 backup type tabs'); + assert + .dom(AUTH_FORM.tabBtn('userpass')) + .hasAttribute('aria-selected', 'true', 'it selects the first backup type'); + await this.assertPathInput(assert); + assert.dom(GENERAL.backButton).doesNotExist(); + assert.dom(GENERAL.button('Sign in with other methods')).doesNotExist(); + }); + + module('all methods have visible mounts', function (hooks) { + hooks.beforeEach(function () { + this.loginSettings = { + defaultType: 'oidc', + backupTypes: ['userpass', 'ldap'], + }; + this.visibleAuthMounts = SYS_INTERNAL_UI_MOUNTS; + }); + + test('(default+backups): it hides advanced settings for both views', async function (assert) { + await this.renderComponent(); + assert.dom(AUTH_FORM.tabBtn('oidc')).hasText('OIDC', 'it renders default method'); + assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'only one tab renders'); + this.assertPathInput(assert, { isHidden: true, value: 'my-oidc/' }); + await click(GENERAL.button('Sign in with other methods')); + assert.dom(AUTH_FORM.tabs).exists({ count: 2 }, 'it renders 2 backup type tabs'); + assert + .dom(AUTH_FORM.tabBtn('userpass')) + .hasAttribute('aria-selected', 'true', 'it selects the first backup type'); + assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); + assert.dom(GENERAL.inputByAttr('path')).doesNotExist(); + assert.dom(GENERAL.selectByAttr('path')).exists(); // dropdown renders because userpass has 2 mount paths + await click(AUTH_FORM.tabBtn('ldap')); + this.assertPathInput(assert, { isHidden: true, value: 'ldap/' }); + }); + + test('(default only): it hides advanced settings and renders hidden input', async function (assert) { + this.loginSettings.backupTypes = null; + await this.renderComponent(); + assert.dom(AUTH_FORM.tabBtn('oidc')).hasText('OIDC', 'it renders default method'); + assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'only one tab renders'); + assert.dom(AUTH_FORM.authForm('oidc')).exists(); + this.assertPathInput(assert, { isHidden: true, value: 'my-oidc/' }); + assert.dom(GENERAL.backButton).doesNotExist(); + assert.dom(GENERAL.button('Sign in with other methods')).doesNotExist(); + }); + + test('(backups only): it hides advanced settings and renders hidden input', async function (assert) { + this.loginSettings.defaultType = ''; + await this.renderComponent(); + assert.dom(AUTH_FORM.tabs).exists({ count: 2 }, 'it renders 2 backup type tabs'); + assert + .dom(AUTH_FORM.tabBtn('userpass')) + .hasAttribute('aria-selected', 'true', 'it selects the first backup type'); + assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); + assert.dom(GENERAL.backButton).doesNotExist(); + assert.dom(GENERAL.button('Sign in with other methods')).doesNotExist(); + }); + }); + + module('only some methods have visible mounts', function (hooks) { + hooks.beforeEach(function () { + this.loginSettings = { + defaultType: 'oidc', + backupTypes: ['userpass', 'ldap'], + }; + this.mountData = (path) => ({ [path]: SYS_INTERNAL_UI_MOUNTS[path] }); + }); + + test('(default+backups): it hides advanced settings for default with visible mount but it renders for backups', async function (assert) { + this.visibleAuthMounts = { ...this.mountData('my-oidc/') }; + await this.renderComponent(); + this.assertPathInput(assert, { isHidden: true, value: 'my-oidc/' }); + await click(GENERAL.button('Sign in with other methods')); + assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true'); + await this.assertPathInput(assert); + await click(AUTH_FORM.tabBtn('ldap')); + await this.assertPathInput(assert); + }); + + test('(default+backups): it only renders advanced settings for method without mounts', async function (assert) { + // default and only one backup method have visible mounts + this.visibleAuthMounts = { + ...this.mountData('my-oidc/'), + ...this.mountData('userpass/'), + ...this.mountData('userpass2/'), + }; + await this.renderComponent(); + assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); + await click(GENERAL.button('Sign in with other methods')); + assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true'); + assert.dom(GENERAL.selectByAttr('path')).exists(); + assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); + await click(AUTH_FORM.tabBtn('ldap')); + assert.dom(AUTH_FORM.advancedSettings).exists(); + }); + + test('(default+backups): it hides advanced settings for single backup method with mounts', async function (assert) { + this.visibleAuthMounts = { ...this.mountData('ldap/') }; + await this.renderComponent(); + assert.dom(AUTH_FORM.authForm('oidc')).exists(); + assert.dom(AUTH_FORM.advancedSettings).exists(); + await click(GENERAL.button('Sign in with other methods')); + assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true'); + assert.dom(AUTH_FORM.advancedSettings).exists(); + await click(AUTH_FORM.tabBtn('ldap')); + this.assertPathInput(assert, { isHidden: true, value: 'ldap/' }); + }); + + test('(backups only): it hides advanced settings for single method with mounts', async function (assert) { + this.loginSettings.defaultType = ''; + this.visibleAuthMounts = { ...this.mountData('ldap/') }; + await this.renderComponent(); + assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true'); + assert.dom(AUTH_FORM.advancedSettings).exists(); + await click(AUTH_FORM.tabBtn('ldap')); + this.assertPathInput(assert, { isHidden: true, value: 'ldap/' }); + }); + }); + + module('@directLinkData overrides login settings', function (hooks) { + hooks.beforeEach(function () { + this.mountData = SYS_INTERNAL_UI_MOUNTS; + }); + + module('when there are no visible mounts at all', function (hooks) { + hooks.beforeEach(function () { + this.visibleAuthMounts = null; + this.directLinkData = { type: 'okta' }; + }); + + const testHelper = (assert) => { + assert.dom(AUTH_FORM.selectMethod).hasValue('okta'); + assert.dom(AUTH_FORM.authForm('okta')).exists(); + assert.dom(GENERAL.button('Sign in with other methods')).doesNotExist(); + assert.dom(GENERAL.backButton).doesNotExist(); + }; + + test('(default+backups): it renders standard view and selects @directLinkData type from dropdown', async function (assert) { + await this.renderComponent(); + testHelper(assert); + }); + + test('(default only): it renders standard view and selects @directLinkData type from dropdown', async function (assert) { + this.loginSettings.backupTypes = null; + await this.renderComponent(); + testHelper(assert); + }); + + test('(backups only): it renders standard view and selects @directLinkData type from dropdown', async function (assert) { + this.loginSettings.defaultType = ''; + await this.renderComponent(); + testHelper(assert); + }); + }); + + module('when param matches a visible mount path and other visible mounts exist', function (hooks) { + hooks.beforeEach(function () { + this.visibleAuthMounts = { + ...this.mountData, + 'my-okta/': { + description: '', + options: null, + type: 'okta', + }, + }; + this.directLinkData = { path: 'my-okta/', type: 'okta' }; + }); + + const testHelper = async (assert) => { + assert.dom(AUTH_FORM.tabBtn('okta')).hasText('Okta', 'it renders preferred method'); + assert.dom(AUTH_FORM.tabs).exists({ count: 1 }, 'only one tab renders'); + assert.dom(AUTH_FORM.authForm('okta')); + assert.dom(AUTH_FORM.advancedSettings).doesNotExist(); + assert.dom(GENERAL.inputByAttr('path')).hasAttribute('type', 'hidden'); + assert.dom(GENERAL.inputByAttr('path')).hasValue('my-okta/'); + assert.dom(GENERAL.inputByAttr('path')).exists({ count: 1 }); + await click(GENERAL.button('Sign in with other methods')); + assert + .dom(GENERAL.selectByAttr('auth type')) + .exists('it renders dropdown after clicking "Sign in with other"'); + }; + + test('(default+backups): it renders single mount view for @directLinkData', async function (assert) { + await this.renderComponent(); + await testHelper(assert); + }); + + test('(default only): it renders single mount view for @directLinkData', async function (assert) { + this.loginSettings.backupTypes = null; + await this.renderComponent(); + await testHelper(assert); + }); + + test('(backups only): it renders single mount view for @directLinkData', async function (assert) { + this.loginSettings.defaultType = ''; + await this.renderComponent(); + await testHelper(assert); + }); + }); + + module('when param matches a type and other visible mounts exist', function (hooks) { + hooks.beforeEach(function () { + // only type is present in directLinkData because the query param does not match a path with listing_visibility="unauth" + this.directLinkData = { type: 'okta' }; + this.visibleAuthMounts = this.mountData; + }); + + const testHelper = async (assert) => { + assert.dom(GENERAL.backButton).exists('back button renders because other methods have tabs'); + assert.dom(AUTH_FORM.selectMethod).hasValue('okta'); + assert.dom(AUTH_FORM.authForm('okta')).exists(); + assert.dom(GENERAL.button('Sign in with other methods')).doesNotExist(); + await click(GENERAL.backButton); + assert.dom(AUTH_FORM.tabBtn('userpass')).hasAttribute('aria-selected', 'true'); + await click(GENERAL.button('Sign in with other methods')); + assert.dom(AUTH_FORM.selectMethod).exists('it toggles back to dropdown'); + }; + + test('(default+backups): it selects @directLinkData type from dropdown and toggles to tab view', async function (assert) { + await this.renderComponent(); + await testHelper(assert); + }); + + test('(default only): it selects @directLinkData type from dropdown and toggles to tab view', async function (assert) { + this.loginSettings.backupTypes = null; + await this.renderComponent(); + await testHelper(assert); + }); + + test('(backups only): it selects @directLinkData type from dropdown and toggles to tab view', async function (assert) { + this.loginSettings.defaultType = ''; + await this.renderComponent(); + await testHelper(assert); + }); + }); + + module('when param matches a type that matches other visible mounts', function (hooks) { + hooks.beforeEach(function () { + // only type exists because the query param does not match a path with listing_visibility="unauth" + this.directLinkData = { type: 'oidc' }; + this.visibleAuthMounts = this.mountData; + }); + + const testHelper = async (assert) => { + assert.dom(AUTH_FORM.tabBtn('oidc')).hasAttribute('aria-selected', 'true'); + assert.dom(AUTH_FORM.authForm('oidc')).exists(); + assert.dom(GENERAL.backButton).doesNotExist(); + await click(GENERAL.button('Sign in with other methods')); + assert.dom(AUTH_FORM.selectMethod).exists('it toggles to view dropdown'); + await click(GENERAL.backButton); + assert.dom(AUTH_FORM.tabs).exists('it toggles back to tabs'); + }; + + test('(default+backups): it selects @directLinkData type tab and toggles to dropdown view', async function (assert) { + await this.renderComponent(); + await testHelper(assert); + }); + + test('(default only): it selects @directLinkData type tab and toggles to dropdown view', async function (assert) { + this.loginSettings.backupTypes = null; + await this.renderComponent(); + await testHelper(assert); + }); + + test('(backups only): it selects @directLinkData type tab and toggles to dropdown view', async function (assert) { + this.loginSettings.defaultType = ''; + await this.renderComponent(); + await testHelper(assert); + }); + }); + }); +}); diff --git a/ui/tests/integration/components/auth/page/method-authentication-test.js b/ui/tests/integration/components/auth/page/method-authentication-test.js new file mode 100644 index 0000000000..5dc738c30f --- /dev/null +++ b/ui/tests/integration/components/auth/page/method-authentication-test.js @@ -0,0 +1,289 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; +import { click, fillIn, waitUntil } from '@ember/test-helpers'; +import { ERROR_JWT_LOGIN } from 'vault/components/auth/form/oidc-jwt'; +import { fillInLoginFields } from 'vault/tests/helpers/auth/auth-helpers'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import { module, test } from 'qunit'; +import { overrideResponse } from 'vault/tests/helpers/stubs'; +import { RESPONSE_STUBS, TOKEN_DATA } from 'vault/tests/helpers/auth/response-stubs'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { setupRenderingTest } from 'ember-qunit'; +import { triggerMessageEvent, windowStub } from 'vault/tests/helpers/oidc-window-stub'; +import setupTestContext from './setup-test-context'; +import sinon from 'sinon'; + +const methodAuthenticationTests = (test) => { + test('it sets token data on login for default path', async function (assert) { + assert.expect(5); + // Setup + this.stubRequests(); + // Render and log in + await this.renderComponent(); + await fillIn(AUTH_FORM.selectMethod, this.authType); + await fillInLoginFields(this.loginData); + if (this.authType === 'oidc') { + triggerMessageEvent(this.path); + } + await click(GENERAL.submitButton); + await waitUntil(() => this.setTokenDataSpy.calledOnce); + const [tokenName, persistedTokenData] = this.setTokenDataSpy.lastCall.args; + + const expectedData = { + ...TOKEN_DATA[this.authType], + // there are other tests that confirm this calculation happens as expected, just copy value from spy + tokenExpirationEpoch: persistedTokenData.tokenExpirationEpoch, + }; + + assert.strictEqual(tokenName, this.tokenName, 'setTokenData is called with expected token name'); + assert.propEqual(persistedTokenData, expectedData, 'setTokenData is called with expected data'); + + // propEqual failures are challenging to parse in CI so pulling out a couple of important attrs + const { token, displayName, entity_id } = expectedData; + assert.strictEqual(persistedTokenData.token, token, 'setTokenData has expected token'); + assert.strictEqual(persistedTokenData.displayName, displayName, 'setTokenData has expected display name'); + assert.strictEqual(persistedTokenData.entity_id, entity_id, 'setTokenData has expected entity_id'); + }); + + test('it calls onAuthSuccess on submit for custom path', async function (assert) { + assert.expect(1); + // Setup + this.path = `${this.authType}-custom`; + this.loginData = { ...this.loginData, path: this.path }; + this.stubRequests(); + // Render and log in + await this.renderComponent(); + await fillIn(AUTH_FORM.selectMethod, this.authType); + // toggle mount path input to specify custom path + await fillInLoginFields(this.loginData, { toggleOptions: true }); + if (this.authType === 'oidc') { + triggerMessageEvent(this.path); + } + await click(GENERAL.submitButton); + + await waitUntil(() => this.onAuthSuccess.calledOnce); + const [actual] = this.onAuthSuccess.lastCall.args; + const expected = { namespace: '', token: this.tokenName, isRoot: false }; + assert.propEqual(actual, expected, `onAuthSuccess called with: ${JSON.stringify(actual)}`); + }); +}; + +module('Integration | Component | auth | page | method authentication', function (hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function () { + setupTestContext(this); + this.auth = this.owner.lookup('service:auth'); + this.setTokenDataSpy = sinon.spy(this.auth, 'setTokenData'); + }); + + hooks.afterEach(function () { + window.localStorage.clear(); + }); + + module('github', function (hooks) { + hooks.beforeEach(async function () { + this.authType = 'github'; + this.loginData = { token: 'mysupersecuretoken' }; + this.path = this.authType; + this.response = RESPONSE_STUBS.github; + this.tokenName = 'vault-github☃1'; + this.stubRequests = () => { + this.server.post(`/auth/${this.path}/login`, () => this.response); + }; + }); + + methodAuthenticationTests(test); + }); + + module('jwt', function (hooks) { + hooks.beforeEach(async function () { + this.authType = 'jwt'; + this.loginData = { role: 'some-dev', jwt: 'jwttoken' }; + this.path = this.authType; + this.response = RESPONSE_STUBS.jwt.login; + this.tokenName = 'vault-jwt☃1'; + this.routerStub = sinon.stub(this.owner.lookup('service:router'), 'urlFor').returns('123-example.com'); + // Requests are stubbed in the order they are hit + this.stubRequests = () => { + // passing a dynamic path so that even the OIDC form renders the JWT token input + // (there is test coverage elsewhere to assert switching between methods updates the form) + this.server.post('/auth/:path/oidc/auth_url', () => + overrideResponse(400, { errors: [ERROR_JWT_LOGIN] }) + ); + this.server.post(`/auth/${this.path}/login`, () => this.response); + this.server.get(`/auth/token/lookup-self`, () => RESPONSE_STUBS.jwt['lookup-self']); + }; + }); + + hooks.afterEach(function () { + this.routerStub.restore(); + }); + + methodAuthenticationTests(test); + }); + + module('ldap', function (hooks) { + hooks.beforeEach(async function () { + this.authType = 'ldap'; + this.loginData = { username: 'matilda', password: 'password' }; + this.path = this.authType; + this.response = RESPONSE_STUBS.ldap; + this.tokenName = 'vault-ldap☃1'; + + this.stubRequests = () => { + this.server.post(`/auth/${this.path}/login/${this.loginData.username}`, () => this.response); + }; + }); + + methodAuthenticationTests(test); + }); + + module('oidc', function (hooks) { + hooks.beforeEach(async function () { + this.authType = 'oidc'; + this.loginData = { role: 'some-dev' }; + this.path = this.authType; + this.response = RESPONSE_STUBS.oidc['oidc/callback']; + this.tokenName = 'vault-token☃1'; + // Requests are stubbed in the order they are hit + this.stubRequests = () => { + this.server.post(`/auth/${this.path}/oidc/auth_url`, () => { + return { data: { auth_url: 'http://dev-foo-bar.com' } }; + }); + this.server.get(`/auth/${this.path}/oidc/callback`, () => this.response); + this.server.get(`/auth/token/lookup-self`, () => RESPONSE_STUBS.oidc['lookup-self']); + }; + + // additional OIDC setup + this.routerStub = sinon.stub(this.owner.lookup('service:router'), 'urlFor').returns('123-example.com'); + this.windowStub = windowStub(); + }); + + hooks.afterEach(function () { + this.routerStub.restore(); + this.windowStub.restore(); + }); + + methodAuthenticationTests(test); + }); + + module('okta', function (hooks) { + hooks.beforeEach(async function () { + this.authType = 'okta'; + this.loginData = { username: 'matilda', password: 'password' }; + this.path = this.authType; + this.response = RESPONSE_STUBS.okta; + this.tokenName = 'vault-okta☃1'; + this.stubRequests = () => { + this.server.post(`/auth/${this.path}/login/${this.loginData.username}`, () => this.response); + }; + }); + + methodAuthenticationTests(test); + }); + + module('radius', function (hooks) { + hooks.beforeEach(async function () { + this.authType = 'radius'; + this.loginData = { username: 'matilda', password: 'password' }; + this.path = this.authType; + this.response = RESPONSE_STUBS.radius; + this.tokenName = 'vault-radius☃1'; + this.stubRequests = () => { + this.server.post(`/auth/${this.path}/login/${this.loginData.username}`, () => this.response); + }; + }); + + methodAuthenticationTests(test); + }); + + module('token', function (hooks) { + hooks.beforeEach(async function () { + this.authType = 'token'; + this.tokenName = 'vault-token☃1'; + this.server.get('/auth/token/lookup-self', () => RESPONSE_STUBS.token); + }); + + test('it sets token data and calls onAuthSuccess', async function (assert) { + assert.expect(6); + await this.renderComponent(); + await fillIn(AUTH_FORM.selectMethod, this.authType); + await fillInLoginFields({ token: 'mysupersecuretoken' }); + await click(GENERAL.submitButton); + + const [actual] = this.onAuthSuccess.lastCall.args; + const expected = { namespace: '', token: this.tokenName, isRoot: false }; + assert.propEqual(actual, expected, `onAuthSuccess called with: ${JSON.stringify(actual)}`); + + const [tokenName, persistedTokenData] = this.setTokenDataSpy.lastCall.args; + const expectedTokenData = { + ...TOKEN_DATA[this.authType], + // there are other tests that confirm this calculation happens as expected, just copy value from spy + tokenExpirationEpoch: persistedTokenData.tokenExpirationEpoch, + }; + assert.strictEqual(tokenName, this.tokenName, 'setTokenData is called with expected token name'); + assert.propEqual(persistedTokenData, expectedTokenData, 'setTokenData is called with expected data'); + + // propEqual failures are challenging to parse in CI so pulling out a couple of important attrs + const { token, displayName, entity_id } = expectedTokenData; + assert.strictEqual(persistedTokenData.token, token, 'setTokenData has expected token'); + assert.strictEqual( + persistedTokenData.displayName, + displayName, + 'setTokenData has expected display name' + ); + assert.strictEqual(persistedTokenData.entity_id, entity_id, 'setTokenData has expected entity_id'); + }); + }); + + module('userpass', function (hooks) { + hooks.beforeEach(async function () { + this.authType = 'userpass'; + this.loginData = { username: 'matilda', password: 'password' }; + this.path = this.authType; + this.response = RESPONSE_STUBS.userpass; + this.tokenName = 'vault-userpass☃1'; + this.stubRequests = () => { + this.server.post(`/auth/${this.path}/login/${this.loginData.username}`, () => this.response); + }; + }); + + methodAuthenticationTests(test); + }); + + // ENTERPRISE METHODS + module('saml', function (hooks) { + hooks.beforeEach(async function () { + this.version.type = 'enterprise'; + this.authType = 'saml'; + this.path = this.authType; + this.loginData = { role: 'some-dev' }; + this.response = RESPONSE_STUBS.saml['saml/token']; + this.tokenName = 'vault-token☃1'; + // Requests are stubbed in the order they are hit + this.stubRequests = () => { + this.server.put(`/auth/${this.path}/sso_service_url`, () => ({ + data: { + sso_service_url: 'test/fake/sso/route', + token_poll_id: '1234', + }, + })); + this.server.put(`/auth/${this.path}/token`, () => this.response); + this.server.get(`/auth/token/lookup-self`, () => RESPONSE_STUBS.saml['lookup-self']); + }; + this.windowStub = windowStub(); + }); + + hooks.afterEach(function () { + this.windowStub.restore(); + }); + + methodAuthenticationTests(test); + }); +}); diff --git a/ui/tests/integration/components/auth/page/mfa-test.js b/ui/tests/integration/components/auth/page/mfa-test.js new file mode 100644 index 0000000000..a7b6307901 --- /dev/null +++ b/ui/tests/integration/components/auth/page/mfa-test.js @@ -0,0 +1,281 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { constraintId, setupTotpMfaResponse } from 'vault/tests/helpers/mfa/mfa-helpers'; +import setupTestContext from './setup-test-context'; +import { ERROR_JWT_LOGIN } from 'vault/components/auth/form/oidc-jwt'; +import { overrideResponse } from 'vault/tests/helpers/stubs'; +import sinon from 'sinon'; +import { triggerMessageEvent, windowStub } from 'vault/tests/helpers/oidc-window-stub'; +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 { fillInLoginFields } from 'vault/tests/helpers/auth/auth-helpers'; +import { click, fillIn, waitFor } from '@ember/test-helpers'; + +const mfaTests = (test) => { + test('it displays mfa requirement for default paths', async function (assert) { + const loginKeys = Object.keys(this.loginData); + assert.expect(3 + loginKeys.length); + this.stubRequests(); + await this.renderComponent(); + + // Fill in login form + await fillIn(AUTH_FORM.selectMethod, this.authType); + await fillInLoginFields(this.loginData); + + if (this.authType === 'oidc') { + // fires "message" event which methods that rely on popup windows wait for + // pass mount path which is used to set :mount param in the callback url => /auth/:mount/oidc/callback + triggerMessageEvent(this.path); + } + + await click(GENERAL.submitButton); + await waitFor(MFA_SELECTORS.mfaForm); + assert + .dom(MFA_SELECTORS.mfaForm) + .hasText( + 'Back Multi-factor authentication is enabled for your account. Enter your authentication code to log in. TOTP passcode Verify' + ); + await click(GENERAL.backButton); + assert.dom(AUTH_FORM.form).exists('clicking back returns to auth form'); + assert.dom(AUTH_FORM.selectMethod).hasValue(this.authType, 'preserves method type on back'); + for (const field of loginKeys) { + assert.dom(GENERAL.inputByAttr(field)).hasValue('', `${field} input clears on back`); + } + }); + + test('it displays mfa requirement for custom paths', async function (assert) { + this.path = `${this.authType}-custom`; + const loginKeys = Object.keys(this.loginData); + assert.expect(3 + loginKeys.length); + this.stubRequests(); + await this.renderComponent(); + + // Fill in login form + await fillIn(AUTH_FORM.selectMethod, this.authType); + // Toggle more options to input a custom mount path + await fillInLoginFields({ ...this.loginData, path: this.path }, { toggleOptions: true }); + + if (this.authType === 'oidc') { + // fires "message" event which methods that rely on popup windows wait for + triggerMessageEvent(this.path); + } + + await click(GENERAL.submitButton); + await waitFor(MFA_SELECTORS.mfaForm); + assert + .dom(MFA_SELECTORS.mfaForm) + .hasText( + 'Back Multi-factor authentication is enabled for your account. Enter your authentication code to log in. TOTP passcode Verify' + ); + await click(GENERAL.backButton); + assert.dom(AUTH_FORM.form).exists('clicking back returns to auth form'); + assert.dom(AUTH_FORM.selectMethod).hasValue(this.authType, 'preserves method type on back'); + for (const field of loginKeys) { + assert.dom(GENERAL.inputByAttr(field)).hasValue('', `${field} input clears on back`); + } + }); + + test('it submits mfa requirement for default paths', async function (assert) { + assert.expect(2); + this.stubRequests(); + await this.renderComponent(); + + const expectedOtp = '12345'; + this.server.post('/sys/mfa/validate', async (_, req) => { + const [actualOtp] = JSON.parse(req.requestBody).mfa_payload[constraintId]; + assert.true(true, 'it makes request to mfa validate endpoint'); + assert.strictEqual(actualOtp, expectedOtp, 'payload contains otp'); + }); + + // Fill in login form + await fillIn(AUTH_FORM.selectMethod, this.authType); + await fillInLoginFields(this.loginData); + + if (this.authType === 'oidc') { + // fires "message" event which methods that rely on popup windows wait for + triggerMessageEvent(this.path); + } + + await click(GENERAL.submitButton); + await waitFor(MFA_SELECTORS.mfaForm); + await fillIn(MFA_SELECTORS.passcode(0), expectedOtp); + await click(MFA_SELECTORS.validate); + }); + + test('it submits mfa requirement for custom paths', async function (assert) { + assert.expect(2); + this.path = `${this.authType}-custom`; + this.stubRequests(); + await this.renderComponent(); + + const expectedOtp = '12345'; + this.server.post('/sys/mfa/validate', async (_, req) => { + const [actualOtp] = JSON.parse(req.requestBody).mfa_payload[constraintId]; + assert.true(true, 'it makes request to mfa validate endpoint'); + assert.strictEqual(actualOtp, expectedOtp, 'payload contains otp'); + }); + + // Fill in login form + await fillIn(AUTH_FORM.selectMethod, this.authType); + // Toggle more options to input a custom mount path + await fillInLoginFields({ ...this.loginData, path: this.path }, { toggleOptions: true }); + + if (this.authType === 'oidc') { + triggerMessageEvent(this.path); + } + + await click(GENERAL.submitButton); + await waitFor(MFA_SELECTORS.mfaForm); + await fillIn(MFA_SELECTORS.passcode(0), expectedOtp); + await click(MFA_SELECTORS.validate); + }); +}; + +module('Integration | Component | auth | page | mfa', function (hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(async function () { + setupTestContext(this); + }); + + module('github', function (hooks) { + hooks.beforeEach(async function () { + this.authType = 'github'; + this.loginData = { token: 'mysupersecuretoken' }; + this.path = this.authType; + this.stubRequests = () => { + this.server.post(`/auth/${this.path}/login`, () => setupTotpMfaResponse(this.path)); + }; + }); + + mfaTests(test); + }); + + module('jwt', function (hooks) { + hooks.beforeEach(async function () { + this.authType = 'jwt'; + this.loginData = { role: 'some-dev', jwt: 'jwttoken' }; + this.path = this.authType; + this.routerStub = sinon.stub(this.owner.lookup('service:router'), 'urlFor').returns('123-example.com'); + + this.stubRequests = () => { + this.server.post('/auth/:path/oidc/auth_url', () => + overrideResponse(400, { errors: [ERROR_JWT_LOGIN] }) + ); + this.server.post(`/auth/${this.path}/login`, () => setupTotpMfaResponse(this.path)); + }; + }); + + hooks.afterEach(function () { + this.routerStub.restore(); + }); + + mfaTests(test); + }); + + module('oidc', function (hooks) { + hooks.beforeEach(async function () { + this.authType = 'oidc'; + this.loginData = { role: 'some-dev' }; + this.path = this.authType; + // Requests are stubbed in the order they are hit + this.stubRequests = () => { + this.server.post(`/auth/${this.path}/oidc/auth_url`, () => { + return { data: { auth_url: 'http://dev-foo-bar.com' } }; + }); + this.server.get(`/auth/${this.path}/oidc/callback`, () => setupTotpMfaResponse(this.path)); + }; + + // additional OIDC setup + this.routerStub = sinon.stub(this.owner.lookup('service:router'), 'urlFor').returns('123-example.com'); + this.windowStub = windowStub(); + }); + + hooks.afterEach(function () { + this.routerStub.restore(); + this.windowStub.restore(); + }); + + mfaTests(test); + }); + + module('username and password methods', function (hooks) { + hooks.beforeEach(async function () { + this.loginData = { username: 'matilda', password: 'password' }; + this.stubRequests = () => { + this.server.post(`/auth/${this.path}/login/matilda`, () => setupTotpMfaResponse(this.path)); + }; + }); + + module('ldap', function (hooks) { + hooks.beforeEach(async function () { + this.authType = 'ldap'; + this.path = this.authType; + }); + + mfaTests(test); + }); + + module('okta', function (hooks) { + hooks.beforeEach(async function () { + this.authType = 'okta'; + this.path = this.authType; + }); + + mfaTests(test); + }); + + module('radius', function (hooks) { + hooks.beforeEach(async function () { + this.authType = 'radius'; + this.path = this.authType; + }); + + mfaTests(test); + }); + + module('userpass', function (hooks) { + hooks.beforeEach(async function () { + this.authType = 'userpass'; + this.path = this.authType; + }); + + mfaTests(test); + }); + }); + + // ENTERPRISE METHODS + module('saml', function (hooks) { + hooks.beforeEach(async function () { + this.version.type = 'enterprise'; + this.authType = 'saml'; + this.path = this.authType; + this.loginData = { role: 'some-dev' }; + // Requests are stubbed in the order they are hit + this.stubRequests = () => { + this.server.put(`/auth/${this.path}/sso_service_url`, () => ({ + data: { + sso_service_url: 'test/fake/sso/route', + token_poll_id: '1234', + }, + })); + this.server.put(`/auth/${this.path}/token`, () => setupTotpMfaResponse(this.authType)); + }; + this.windowStub = windowStub(); + }); + + hooks.afterEach(function () { + this.windowStub.restore(); + }); + + mfaTests(test); + }); +}); diff --git a/ui/tests/integration/components/auth/page/page-test.js b/ui/tests/integration/components/auth/page/page-test.js new file mode 100644 index 0000000000..9f30aa7530 --- /dev/null +++ b/ui/tests/integration/components/auth/page/page-test.js @@ -0,0 +1,93 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { click, fillIn, waitFor } from '@ember/test-helpers'; +import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import { CSP_ERROR } from 'vault/components/auth/page'; +import setupTestContext from './setup-test-context'; + +/* +The AuthPage parents much of the authentication workflow and so it can be used to test lots of auth functionality. +This file tests the base component functionality. The other files test method authentication, listing visibility, +login settings (enterprise feature), and mfa. +*/ +module('Integration | Component | auth | page', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + setupTestContext(this); + }); + + 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 and disables namespace input when oidc provider query param is present', async function (assert) { + this.oidcProviderQueryParam = 'myprovider'; + this.version.features = ['Namespaces']; + await this.renderComponent(); + assert.dom(AUTH_FORM.logo).exists(); + assert.dom(GENERAL.inputByAttr('namespace')).isDisabled(); + 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 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 passes query param to namespace input', async function (assert) { + this.version.features = ['Namespaces']; + this.namespaceQueryParam = 'ns-1'; + await this.renderComponent(); + assert.dom(GENERAL.inputByAttr('namespace')).hasValue(this.namespaceQueryParam); + }); + + test('it does not render the namespace input on community', async function (assert) { + this.version.type = 'community'; + this.version.features = []; + await this.renderComponent(); + assert.dom(GENERAL.inputByAttr('namespace')).doesNotExist(); + }); + + test('it does not render the namespace input on enterprise without the "Namespaces" feature', async function (assert) { + this.version.type = 'enterprise'; + this.version.features = []; + await this.renderComponent(); + assert.dom(GENERAL.inputByAttr('namespace')).doesNotExist(); + }); + + test('it selects type in the dropdown if direct link just has type', async function (assert) { + this.directLinkData = { type: 'oidc' }; + await this.renderComponent(); + assert.dom(AUTH_FORM.tabBtn('oidc')).doesNotExist('tab does not render'); + assert.dom(GENERAL.selectByAttr('auth type')).hasValue('oidc', 'dropdown has type selected'); + assert.dom(AUTH_FORM.authForm('oidc')).exists(); + assert.dom(GENERAL.inputByAttr('role')).exists(); + await click(AUTH_FORM.advancedSettings); + assert.dom(GENERAL.inputByAttr('path')).exists({ count: 1 }); + assert.dom(GENERAL.backButton).doesNotExist(); + assert + .dom(GENERAL.button('Sign in with other methods')) + .doesNotExist('"Sign in with other methods" does not render'); + }); +}); diff --git a/ui/tests/integration/components/auth/page/setup-test-context.js b/ui/tests/integration/components/auth/page/setup-test-context.js new file mode 100644 index 0000000000..13268016d0 --- /dev/null +++ b/ui/tests/integration/components/auth/page/setup-test-context.js @@ -0,0 +1,33 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { render } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import sinon from 'sinon'; + +export default (context) => { + context.version = context.owner.lookup('service:version'); + context.cluster = { id: '1' }; + context.directLinkData = null; + context.loginSettings = null; + context.namespaceQueryParam = ''; + context.oidcProviderQueryParam = ''; + context.onAuthSuccess = sinon.spy(); + context.onNamespaceUpdate = sinon.spy(); + context.visibleAuthMounts = false; + + context.renderComponent = () => { + return render(hbs``); + }; +};