Skip to content

Commit c881782

Browse files
UI: Add auth tests (#30033)
* rename page test to login form * add username/password tests to auth page test * add github and generalize tests * finish standard auth types for page test * add tests for onNamespaceInput * fix accessibility violation * add oidc provider qp test * move helper into test * move destructured arg * address oidc auth method flakiness...maybe? * cleanup unused fake window methods * add comment why... * fix diff * fix header * finish mfa acceptance tests move mfa selectors to folder
1 parent 1cb6eac commit c881782

File tree

13 files changed

+651
-212
lines changed

13 files changed

+651
-212
lines changed

ui/app/components/auth/page.hbs

+2-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
<:header>
2222
{{#if @oidcProviderQueryParam}}
2323
<div class="box is-shadowless is-flex-v-centered" data-test-auth-logo>
24-
<LogoEdition aria-label="Sign in with Hashicorp Vault" />
24+
<LogoEdition aria-label="Sign in with Hashicorp Vault" role="img" />
2525
</div>
2626
{{else}}
2727
<div class="is-flex-v-centered has-bottom-margin-xxl">
@@ -37,6 +37,7 @@
3737
@isIconOnly={{true}}
3838
@color="tertiary"
3939
{{on "click" (fn (mut this.mfaAuthData) null)}}
40+
data-test-back-button
4041
/>
4142
{{/if}}
4243
<h1 class="title is-3">

ui/app/components/auth/page.js

+12-2
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,22 @@ import { action } from '@ember/object';
1010

1111
/**
1212
* @module AuthPage
13-
* The Auth::Page wraps OktaNumberChallenge and AuthForm to manage the login flow and is responsible for calling the authenticate method
13+
* The Auth::Page is the route template for the login splash view. It renders the Auth::LoginForm or MFA component if an
14+
* mfa validation is returned from the auth request. It also handles display logic if there is an oidc provider query param.
1415
*
1516
* @example
16-
* <Auth::Page @namespaceQueryParam={{this.namespaceQueryParam}} @onAuthSuccess={{action "authSuccess"}} @oidcProviderQueryParam={{this.oidcProvider}} @cluster={{this.model}} @onNamespaceUpdate={{perform this.updateNamespace}} />
17+
* <Auth::Page
18+
* @authMethodQueryParam={{this.authMethod}}
19+
* @cluster={{this.model}}
20+
* @namespaceQueryParam={{this.namespaceQueryParam}}
21+
* @oidcProviderQueryParam={{this.oidcProvider}}
22+
* @onAuthSuccess={{action "authSuccess"}}
23+
* @onNamespaceUpdate={{perform this.updateNamespace}}
24+
* @wrappedToken={{this.wrappedToken}}
25+
/>
1726
*
1827
* @param {string} authMethodQueryParam - auth method type to login with, updated by selecting an auth method from the dropdown
28+
* @param {object} cluster - the ember data cluster model. contains information such as cluster id, name and boolean for if the cluster is in standby
1929
* @param {string} namespaceQueryParam - namespace to login with, updated by typing in to the namespace input
2030
* @param {string} oidcProviderQueryParam - oidc provider query param, set in url as "?o=someprovider"
2131
* @param {function} onAuthSuccess - callback task in controller that receives the auth response (after MFA, if enabled) when login is successful

ui/tests/acceptance/auth-test.js

+8-3
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
import { login, loginMethod, loginNs, logout } from 'vault/tests/helpers/auth/auth-helpers';
2020
import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors';
2121
import { v4 as uuidv4 } from 'uuid';
22-
import { GENERAL } from '../helpers/general-selectors';
22+
import { GENERAL } from 'vault/tests/helpers/general-selectors';
2323

2424
const ENT_AUTH_METHODS = ['saml'];
2525
const { rootToken } = VAULT_KEYS;
@@ -263,10 +263,15 @@ module('Acceptance | auth', function (hooks) {
263263
`write auth/${this.userpass}/users/${this.user} password=${this.user} token_policies=${this.policyName}`,
264264
]);
265265

266-
const options = { username: this.user, password: this.user, 'auth-form-mount-path': this.userpass };
266+
const inputValues = {
267+
username: this.user,
268+
password: this.user,
269+
'auth-form-mount-path': this.userpass,
270+
'auth-form-ns-input': this.ns,
271+
};
267272

268273
// login as user just to get token (this is the only way to generate a token in the UI right now..)
269-
await loginMethod('userpass', options, { toggleOptions: true, ns: this.ns });
274+
await loginMethod(inputValues, { authType: 'userpass', toggleOptions: true });
270275
await click('[data-test-user-menu-trigger=""]');
271276
const token = find('[data-test-copy-button]').getAttribute('data-test-copy-button');
272277

ui/tests/acceptance/auth/mfa-test.js

+224
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
/**
2+
* Copyright (c) HashiCorp, Inc.
3+
* SPDX-License-Identifier: BUSL-1.1
4+
*/
5+
6+
import { module, test } from 'qunit';
7+
import { setupApplicationTest } from 'ember-qunit';
8+
import { click, visit, fillIn } from '@ember/test-helpers';
9+
import { setupMirage } from 'ember-cli-mirage/test-support';
10+
import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors';
11+
import { GENERAL } from 'vault/tests/helpers/general-selectors';
12+
import { MFA_SELECTORS } from 'vault/tests/helpers/mfa/mfa-selectors';
13+
import { constraintId, setupTotpMfaResponse } from 'vault/tests/helpers/mfa/mfa-helpers';
14+
import { fillInLoginFields } from 'vault/tests/helpers/auth/auth-helpers';
15+
import { callbackData, WindowStub } from 'vault/tests/helpers/oidc-window-stub';
16+
import sinon from 'sinon';
17+
18+
const ENT_ONLY = ['saml'];
19+
20+
// See AUTH_METHOD_TEST_CASES for how request data maps to method types
21+
// authRequest is the request made on submit and what returns mfa_validation requirements (if any)
22+
// additionalRequest are any third party requests the auth method expects
23+
const REQUEST_DATA = {
24+
username: {
25+
loginData: { username: 'matilda', password: 'password' },
26+
stubRequests: (server, path) =>
27+
server.post(`/auth/${path}/login/matilda`, () => setupTotpMfaResponse(path)),
28+
},
29+
github: {
30+
loginData: { token: 'mysupersecuretoken' },
31+
stubRequests: (server, path) => server.post(`/auth/${path}/login`, () => setupTotpMfaResponse(path)),
32+
},
33+
oidc: {
34+
loginData: { role: 'some-dev' },
35+
hasPopupWindow: true,
36+
stubRequests: (server, path) => {
37+
server.get(`/auth/${path}/oidc/callback`, () => setupTotpMfaResponse(path));
38+
server.post(`/auth/${path}/oidc/auth_url`, () => ({
39+
data: { auth_url: 'http://dev-foo-bar.com' },
40+
}));
41+
},
42+
},
43+
saml: {
44+
loginData: { role: 'some-dev' },
45+
hasPopupWindow: true,
46+
stubRequests: (server, path) => {
47+
server.put(`/auth/${path}/token`, () => setupTotpMfaResponse(path));
48+
server.put(`/auth/${path}/sso_service_url`, () => ({
49+
data: { sso_service_url: 'http://sso-url.hashicorp.com/service', token_poll_id: '1234' },
50+
}));
51+
},
52+
},
53+
};
54+
55+
// maps auth type to request data (line breaks to help separate and clarify which methods share request paths)
56+
const AUTH_METHOD_TEST_CASES = [
57+
{ authType: 'github', options: REQUEST_DATA.github },
58+
59+
{ authType: 'userpass', options: REQUEST_DATA.username },
60+
{ authType: 'ldap', options: REQUEST_DATA.username },
61+
{ authType: 'okta', options: REQUEST_DATA.username },
62+
{ authType: 'radius', options: REQUEST_DATA.username },
63+
64+
{ authType: 'oidc', options: REQUEST_DATA.oidc },
65+
{ authType: 'jwt', options: REQUEST_DATA.oidc },
66+
67+
// ENTERPRISE ONLY
68+
{ authType: 'saml', options: REQUEST_DATA.saml },
69+
];
70+
71+
for (const method of AUTH_METHOD_TEST_CASES) {
72+
const { authType, options } = method;
73+
const isEntMethod = ENT_ONLY.includes(authType);
74+
// adding "enterprise" to the module title filters it out of the test runner for the CE repo
75+
module(`Acceptance | auth | mfa ${authType}${isEntMethod ? ' enterprise' : ''}`, function (hooks) {
76+
setupApplicationTest(hooks);
77+
setupMirage(hooks);
78+
79+
hooks.beforeEach(async function () {
80+
if (options?.hasPopupWindow) {
81+
this.windowStub = sinon.stub(window, 'open').callsFake(() => new WindowStub());
82+
}
83+
await visit('/vault/auth');
84+
});
85+
86+
hooks.afterEach(function () {
87+
if (options?.hasPopupWindow) {
88+
this.windowStub.restore();
89+
}
90+
});
91+
92+
test(`${authType}: it displays mfa requirement for default paths`, async function (assert) {
93+
this.mountPath = authType;
94+
options.stubRequests(this.server, this.mountPath);
95+
96+
const loginKeys = Object.keys(options.loginData);
97+
assert.expect(3 + loginKeys.length);
98+
99+
// Fill in login form
100+
await fillIn(AUTH_FORM.method, authType);
101+
await fillInLoginFields(options.loginData);
102+
103+
if (options?.hasPopupWindow) {
104+
// fires "message" event which methods that rely on popup windows wait for
105+
setTimeout(() => {
106+
// set path which is used to set :mount param in the callback url => /auth/:mount/oidc/callback
107+
window.postMessage(callbackData({ path: this.mountPath }), window.origin);
108+
}, 50);
109+
}
110+
111+
await click(AUTH_FORM.login);
112+
assert
113+
.dom(MFA_SELECTORS.mfaForm)
114+
.hasText(
115+
'Multi-factor authentication is enabled for your account. Enter your authentication code to log in. TOTP passcode Verify'
116+
);
117+
await click(GENERAL.backButton);
118+
assert.dom(AUTH_FORM.form).exists('clicking back returns to auth form');
119+
assert.dom(GENERAL.selectByAttr('auth-method')).hasValue(authType, 'preserves method type on back');
120+
for (const field of loginKeys) {
121+
assert.dom(AUTH_FORM.input(field)).hasValue('', `${field} input clears on back`);
122+
}
123+
});
124+
125+
test(`${authType}: it displays mfa requirement for custom paths`, async function (assert) {
126+
this.mountPath = `${authType}-custom`;
127+
options.stubRequests(this.server, this.mountPath);
128+
const loginKeys = Object.keys(options.loginData);
129+
assert.expect(3 + loginKeys.length);
130+
131+
// Fill in login form
132+
await fillIn(AUTH_FORM.method, authType);
133+
// Toggle more options to input a custom mount path
134+
await fillInLoginFields(
135+
{ ...options.loginData, 'auth-form-mount-path': this.mountPath },
136+
{ toggleOptions: true }
137+
);
138+
139+
if (options?.hasPopupWindow) {
140+
// fires "message" event which methods that rely on popup windows wait for
141+
setTimeout(() => {
142+
// set path which is used to set :mount param in the callback url => /auth/:mount/oidc/callback
143+
window.postMessage(callbackData({ path: this.mountPath }), window.origin);
144+
}, 50);
145+
}
146+
147+
await click(AUTH_FORM.login);
148+
assert
149+
.dom(MFA_SELECTORS.mfaForm)
150+
.hasText(
151+
'Multi-factor authentication is enabled for your account. Enter your authentication code to log in. TOTP passcode Verify'
152+
);
153+
await click(GENERAL.backButton);
154+
assert.dom(AUTH_FORM.form).exists('clicking back returns to auth form');
155+
assert.dom(GENERAL.selectByAttr('auth-method')).hasValue(authType, 'preserves method type on back');
156+
for (const field of loginKeys) {
157+
assert.dom(AUTH_FORM.input(field)).hasValue('', `${field} input clears on back`);
158+
}
159+
});
160+
161+
test(`${authType}: it submits mfa requirement for default paths`, async function (assert) {
162+
assert.expect(2);
163+
this.mountPath = authType;
164+
options.stubRequests(this.server, this.mountPath);
165+
166+
const expectedOtp = '12345';
167+
server.post('/sys/mfa/validate', async (_, req) => {
168+
const [actualOtp] = JSON.parse(req.requestBody).mfa_payload[constraintId];
169+
assert.true(true, 'it makes request to mfa validate endpoint');
170+
assert.strictEqual(actualOtp, expectedOtp, 'payload contains otp');
171+
});
172+
173+
// Fill in login form
174+
await fillIn(AUTH_FORM.method, authType);
175+
await fillInLoginFields(options.loginData);
176+
177+
if (options?.hasPopupWindow) {
178+
// fires "message" event which methods that rely on popup windows wait for
179+
setTimeout(() => {
180+
// set path which is used to set :mount param in the callback url => /auth/:mount/oidc/callback
181+
window.postMessage(callbackData({ path: this.mountPath }), window.origin);
182+
}, 50);
183+
}
184+
185+
await click(AUTH_FORM.login);
186+
await fillIn(MFA_SELECTORS.passcode(0), expectedOtp);
187+
await click(MFA_SELECTORS.validate);
188+
});
189+
190+
test(`${authType}: it submits mfa requirement for custom paths`, async function (assert) {
191+
assert.expect(2);
192+
193+
this.mountPath = `${authType}-custom`;
194+
options.stubRequests(this.server, this.mountPath);
195+
196+
const expectedOtp = '12345';
197+
server.post('/sys/mfa/validate', async (_, req) => {
198+
const [actualOtp] = JSON.parse(req.requestBody).mfa_payload[constraintId];
199+
assert.true(true, 'it makes request to mfa validate endpoint');
200+
assert.strictEqual(actualOtp, expectedOtp, 'payload contains otp');
201+
});
202+
203+
// Fill in login form
204+
await fillIn(AUTH_FORM.method, authType);
205+
// Toggle more options to input a custom mount path
206+
await fillInLoginFields(
207+
{ ...options.loginData, 'auth-form-mount-path': this.mountPath },
208+
{ toggleOptions: true }
209+
);
210+
211+
if (options?.hasPopupWindow) {
212+
// fires "message" event which methods that rely on popup windows wait for
213+
setTimeout(() => {
214+
// set path which is used to set :mount param in the callback url => /auth/:mount/oidc/callback
215+
window.postMessage(callbackData({ path: this.mountPath }), window.origin);
216+
}, 50);
217+
}
218+
219+
await click(AUTH_FORM.login);
220+
await fillIn(MFA_SELECTORS.passcode(0), expectedOtp);
221+
await click(MFA_SELECTORS.validate);
222+
});
223+
});
224+
}

ui/tests/acceptance/oidc-auth-method-test.js

+9-14
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,17 @@ import { setupApplicationTest } from 'ember-qunit';
88
import { click, fillIn, find, waitUntil } from '@ember/test-helpers';
99
import authPage from 'vault/tests/pages/auth';
1010
import { setupMirage } from 'ember-cli-mirage/test-support';
11-
import { fakeWindow, buildMessage } from '../helpers/oidc-window-stub';
11+
import { WindowStub, buildMessage } from 'vault/tests/helpers/oidc-window-stub';
1212
import sinon from 'sinon';
13-
import { later, _cancelTimers as cancelTimers } from '@ember/runloop';
1413
import { Response } from 'miragejs';
15-
import { setupTotpMfaResponse } from 'vault/tests/helpers/auth/mfa-helpers';
14+
import { setupTotpMfaResponse } from 'vault/tests/helpers/mfa/mfa-helpers';
1615

1716
module('Acceptance | oidc auth method', function (hooks) {
1817
setupApplicationTest(hooks);
1918
setupMirage(hooks);
2019

2120
hooks.beforeEach(function () {
22-
this.openStub = sinon.stub(window, 'open').callsFake(() => fakeWindow.create());
21+
this.openStub = sinon.stub(window, 'open').callsFake(() => new WindowStub());
2322

2423
this.setupMocks = (assert) => {
2524
this.server.post('/auth/oidc/oidc/auth_url', () => ({
@@ -67,10 +66,9 @@ module('Acceptance | oidc auth method', function (hooks) {
6766
this.setupMocks(assert);
6867

6968
await this.selectMethod('oidc');
70-
later(() => {
69+
setTimeout(() => {
7170
window.postMessage(buildMessage().data, window.origin);
72-
cancelTimers();
73-
}, 100);
71+
}, 50);
7472

7573
await click('[data-test-auth-submit]');
7674
});
@@ -96,21 +94,19 @@ module('Acceptance | oidc auth method', function (hooks) {
9694
});
9795

9896
await this.selectMethod('oidc', true);
99-
later(() => {
97+
setTimeout(() => {
10098
window.postMessage(buildMessage().data, window.origin);
101-
cancelTimers();
10299
}, 50);
103-
104100
await click('[data-test-auth-submit]');
105101
});
106102

107103
// coverage for bug where token was selected as auth method for oidc and jwt
108104
test('it should populate oidc auth method on logout', async function (assert) {
109105
this.setupMocks();
110106
await this.selectMethod('oidc');
111-
later(() => {
107+
108+
setTimeout(() => {
112109
window.postMessage(buildMessage().data, window.origin);
113-
cancelTimers();
114110
}, 50);
115111

116112
await click('[data-test-auth-submit]');
@@ -163,9 +159,8 @@ module('Acceptance | oidc auth method', function (hooks) {
163159
this.setupMocks(assert);
164160
this.server.get('/auth/foo/oidc/callback', () => setupTotpMfaResponse('foo'));
165161
await this.selectMethod('oidc');
166-
later(() => {
162+
setTimeout(() => {
167163
window.postMessage(buildMessage().data, window.origin);
168-
cancelTimers();
169164
}, 50);
170165

171166
await click('[data-test-auth-submit]');

ui/tests/acceptance/saml-auth-method-test.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ import { click, fillIn, find, waitUntil } from '@ember/test-helpers';
1010
import { setupMirage } from 'ember-cli-mirage/test-support';
1111
import { Response } from 'miragejs';
1212
import authPage from 'vault/tests/pages/auth';
13-
import { fakeWindow } from 'vault/tests/helpers/oidc-window-stub';
14-
import { setupTotpMfaResponse } from 'vault/tests/helpers/auth/mfa-helpers';
13+
import { WindowStub } from 'vault/tests/helpers/oidc-window-stub';
14+
import { setupTotpMfaResponse } from 'vault/tests/helpers/mfa/mfa-helpers';
1515

1616
module('Acceptance | enterprise saml auth method', function (hooks) {
1717
setupApplicationTest(hooks);
1818
setupMirage(hooks);
1919

2020
hooks.beforeEach(function () {
21-
this.openStub = sinon.stub(window, 'open').callsFake(() => fakeWindow.create());
21+
this.openStub = sinon.stub(window, 'open').callsFake(() => new WindowStub());
2222
this.server.put('/auth/saml/sso_service_url', () => ({
2323
data: {
2424
sso_service_url: 'http://sso-url.hashicorp.com/service',

ui/tests/helpers/auth/auth-form-selectors.ts

+2
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@ export const AUTH_FORM = {
1414
mountPathInput: '[data-test-auth-form-mount-path]',
1515
moreOptions: '[data-test-auth-form-options-toggle]',
1616
namespaceInput: '[data-test-auth-form-ns-input]',
17+
logo: '[data-test-auth-logo]',
18+
helpText: '[data-test-auth-helptext]',
1719
};

0 commit comments

Comments
 (0)