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

* move token unwrap functionality to page component

* update mfa test

* remove wrapped_token logic from page component

* more cleanup to relocate unwrap logic

* move wrapped_token to route

* move unwrap tests to acceptance

* move mfa form back

* add some padding

* update mfa-form tests

* get param from params

* wait for auth form on back

* run rests

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

* initial implementation of mfa validation for sso methods

* update typescript interfaces

* add stopgap changes to auth service

* switch order backend is defined

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

* attempt to stabilize wrapped_query test

* =update login form test why not

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

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

---------

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

* Move CSP error to page component (#30492)

* initial implementation of mfa validation for sso methods

* update typescript interfaces

* add stopgap changes to auth service

* switch order backend is defined

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

* attempt to stabilize wrapped_query test

* =update login form test why not

* move csp error to page component

* move csp error to page component

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

* rename namespace arg to namespaceQueryParam

* move fetch mounts to route

* add margin to sign in button spacing

* update selectors for oidc provider test

* add todo delete comments

* fix arg typo in test

* change method name

* fix args handling tab click

* remove tests that no longer relate to components functionality

* add tests for preselectedAuthType functionality

* move typescript interfaces, fix selector

* add await

* oops

* move format method down, make private

* move tab formatting to the route

* move to page object

* fix token unwrap aborting transition

* not sure what that is doing there..

* add comments

* rename to presetAuthType

* use did-insert instead

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

* replace Auth::LoginForm with Auth::FormTemplate

* first round of test updates

* return null if mounts object is empty

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

* more test updates

* delete listing_visibility test, delete login-form component test

* update divs to Hds::Card::Container

* add overflow class

* remove unused getters

* move requesting stored auth type to page component

* fix typo

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

make comment make more sense

* small cleanup items, update imports

* Delete old auth components (#30527)

* delete old components

* update codeowners

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

* update path input to type=hidden

* add test coverage

* update page test

* update auth route

* delete login form

* update ent test

* consolidate logic in getter

* add more comments

* more comments..

* rename selector

* refresh model as well

* redirect for invalid query params

* move unwrap to redirect

* only redirect on invalid query params

* add tests for query param

* test selector updates

* remove todos, update relevant ones with initials

* add changelog

---------

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

290 lines
10 KiB
JavaScript

/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
import { click, fillIn, find, render } from '@ember/test-helpers';
import sinon from 'sinon';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors';
import { overrideResponse } from 'vault/tests/helpers/stubs';
import { windowStub } from 'vault/tests/helpers/oidc-window-stub';
module('Integration | Component | auth | form | saml', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.authType = 'saml';
this.expectedFields = ['role'];
this.authenticateStub = sinon.stub(this.owner.lookup('service:auth'), 'authenticate');
this.store = this.owner.lookup('service:store');
this.cluster = { id: 1 };
this.onError = sinon.spy();
this.onSuccess = sinon.spy();
this.windowStub = windowStub();
sinon.replaceGetter(window, 'screen', () => ({ height: 600, width: 500 }));
// role request
this.server.put('/auth/saml/sso_service_url', () => {
return {
data: {
sso_service_url: 'https://my-single-sign-on-url.com',
token_poll_id: '4fe2ec01-1f56-b665-0ba2-09c7bca10ae8',
},
};
});
// polling request
this.server.put('/auth/saml/token', () => {
return { auth: { client_token: 'my_token' } };
});
this.renderComponent = ({ yieldBlock = false } = {}) => {
if (yieldBlock) {
return render(hbs`
<Auth::Form::Saml
@authType={{this.authType}}
@cluster={{this.cluster}}
@onError={{this.onError}}
@onSuccess={{this.onSuccess}}
>
<:advancedSettings>
<label for="path">Mount path</label>
<input data-test-input="path" id="path" name="path" type="text" />
</:advancedSettings>
</Auth::Form::Saml>`);
}
return render(hbs`
<Auth::Form::Saml
@authType={{this.authType}}
@cluster={{this.cluster}}
@onError={{this.onError}}
@onSuccess={{this.onSuccess}}
/>`);
};
});
hooks.afterEach(function () {
this.windowStub.restore();
sinon.restore();
});
test('it renders helper text', async function (assert) {
await this.renderComponent();
const id = find(GENERAL.inputByAttr('role')).id;
assert
.dom(`#helper-text-${id}`)
.hasText('Vault will use the default role to sign in if this field is left blank.');
});
test('it renders warning if insecure context is detected', async function (assert) {
sinon.replaceGetter(window, 'isSecureContext', () => false);
await this.renderComponent();
assert
.dom('[data-test-saml-auth-not-allowed]')
.hasText(
'Insecure context detected Logging in with a SAML auth method requires a browser in a secure context. Read more about secure contexts.'
);
});
test('it requests sso_service_url and opens popup on submit if role is empty', async function (assert) {
assert.expect(6);
this.server.put('/auth/saml/sso_service_url', (_, req) => {
const { acs_url, role } = JSON.parse(req.requestBody);
assert.strictEqual(acs_url, `${window.origin}/v1/auth/saml/callback`, 'it builds acs_url for payload');
assert.strictEqual(role, '', 'role has no value');
return {
data: {
sso_service_url: 'https://my-single-sign-on-url.com',
token_poll_id: '4fe2ec01-1f56-b665-0ba2-09c7bca10ae8',
},
};
});
await this.renderComponent();
await click(AUTH_FORM.login);
const [sso_service_url, name, dimensions] = this.windowStub.lastCall.args;
assert.strictEqual(
sso_service_url,
'https://my-single-sign-on-url.com',
'it calls window opener with sso_service_url returned by role request'
);
assert.strictEqual(sso_service_url, 'https://my-single-sign-on-url.com');
assert.strictEqual(name, 'vaultSAMLWindow', 'it calls window opener with expected name');
assert.strictEqual(
dimensions,
'width=500,height=600,resizable,scrollbars=yes,top=0,left=0',
'it calls window opener with expected dimensions'
);
});
test('it requests sso_service_url with inputted role and default path', async function (assert) {
assert.expect(6);
this.server.put('/auth/saml/sso_service_url', (_, req) => {
const { acs_url, role } = JSON.parse(req.requestBody);
assert.strictEqual(acs_url, `${window.origin}/v1/auth/saml/callback`, 'it builds acs_url for payload');
assert.strictEqual(role, 'some-dev', 'payload contains role');
return {
data: {
sso_service_url: 'https://my-single-sign-on-url.com',
token_poll_id: '4fe2ec01-1f56-b665-0ba2-09c7bca10ae8',
},
};
});
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('role'), 'some-dev');
await click(AUTH_FORM.login);
const [sso_service_url, name, dimensions] = this.windowStub.lastCall.args;
assert.strictEqual(
sso_service_url,
'https://my-single-sign-on-url.com',
'it calls window opener with sso_service_url returned by role request'
);
assert.strictEqual(sso_service_url, 'https://my-single-sign-on-url.com');
assert.strictEqual(name, 'vaultSAMLWindow', 'it calls window opener with expected name');
assert.strictEqual(
dimensions,
'width=500,height=600,resizable,scrollbars=yes,top=0,left=0',
'it calls window opener with expected dimensions'
);
});
test('it requests sso_service_url with custom path', async function (assert) {
assert.expect(6);
const path = 'custom-path';
this.server.put(`/auth/${path}/sso_service_url`, (_, req) => {
const { acs_url, role } = JSON.parse(req.requestBody);
assert.strictEqual(
acs_url,
`${window.origin}/v1/auth/${path}/callback`,
'it builds acs_url for payload'
);
assert.strictEqual(role, 'some-dev', 'payload contains role');
return {
data: {
sso_service_url: 'https://my-single-sign-on-url.com',
token_poll_id: '4fe2ec01-1f56-b665-0ba2-09c7bca10ae8',
},
};
});
await this.renderComponent({ yieldBlock: true });
await fillIn(GENERAL.inputByAttr('role'), 'some-dev');
await fillIn(GENERAL.inputByAttr('path'), path);
await click(AUTH_FORM.login);
const [sso_service_url, name, dimensions] = this.windowStub.lastCall.args;
assert.strictEqual(
sso_service_url,
'https://my-single-sign-on-url.com',
'it calls window opener with sso_service_url returned by role request'
);
assert.strictEqual(sso_service_url, 'https://my-single-sign-on-url.com');
assert.strictEqual(name, 'vaultSAMLWindow', 'it calls window opener with expected name');
assert.strictEqual(
dimensions,
'width=500,height=600,resizable,scrollbars=yes,top=0,left=0',
'it calls window opener with expected dimensions'
);
});
test('it polls token request', async function (assert) {
assert.expect(2); // auth/saml/token url should be requested twice
let count = 0;
this.server.put('/auth/saml/token', () => {
count++;
const msg =
count === 1
? 'it makes initial request to token url'
: 'it re-requests token url if httpStatus was 401';
assert.true(true, msg);
if (count === 1) {
return overrideResponse(401);
} else {
return { auth: { client_token: 'my_token' } };
}
});
await this.renderComponent();
await click(AUTH_FORM.login);
});
test('it calls auth service with token request callback data', async function (assert) {
await this.renderComponent();
await click(AUTH_FORM.login);
const [actual] = this.authenticateStub.lastCall.args;
assert.propEqual(
actual.data,
{
token: 'my_token',
},
'auth service "authenticate" method is called token callback data'
);
});
test('it calls onSuccess if auth service authentication is successful', async function (assert) {
const expectedResponse = {
namespace: '',
token: 'my_token',
isRoot: false,
};
// stub happy response
this.authenticateStub.returns(expectedResponse);
await this.renderComponent();
await click(AUTH_FORM.login);
const [actualResponse, methodData] = this.onSuccess.lastCall.args;
assert.propEqual(actualResponse, expectedResponse, 'onSuccess is called with auth response');
assert.strictEqual(methodData.path, undefined, 'onSuccess is called without path value');
assert.strictEqual(methodData.selectedAuth, 'saml', 'onSuccess is called with selected auth type');
});
test('it calls onError if auth service authentication fails', async function (assert) {
this.authenticateStub.throws('permission denied!!');
await this.renderComponent();
await click(AUTH_FORM.login);
const [actual] = this.onError.lastCall.args;
assert.strictEqual(
actual,
'Authentication failed: Sinon-provided permission denied!!',
'onError called with auth service failure'
);
});
test('it calls onError if sso_service_url request fails', async function (assert) {
// role request
this.server.put('/auth/saml/sso_service_url', () => overrideResponse(403));
await this.renderComponent();
await click(AUTH_FORM.login);
const [actual] = this.onError.lastCall.args;
assert.strictEqual(
actual,
'Authentication failed: permission denied',
'onError called with sso_service_url failure'
);
});
test('it calls onError if polling token errors in status code that is NOT 401', async function (assert) {
this.server.put('/auth/saml/token', () => overrideResponse(500));
await this.renderComponent();
await click(AUTH_FORM.login);
const [actual] = this.onError.lastCall.args;
assert.strictEqual(
actual,
'Authentication failed: Ember Data Request PUT /v1/auth/saml/token returned a 500\nPayload (application/json)\n{}',
'onError called with auth failure'
);
});
});