feat: 🎸 create manage custom scopes route and view (#2359)

* feat: 🎸 create manage custom scopes route and view

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-13881

---------

Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>
This commit is contained in:
Lisbet AlvarezandZhihe Li authored and Lisbet Alvarez committed 2024-07-15 17:23:00 -07:00
1 parent 89e072401c
commit 27abf9e2d9
24 files changed
+879 -68

No files matched your search

+26
View File
@@ -81,6 +81,32 @@ export default class RoleModel extends GeneratedRoleModel {
return grantScopes;
}
/**
* Helper for retrieving grant scope ids that are keywords.
* @type {string[]}
*/
get grantScopeKeywords() {
return this.grant_scope_ids.filter((id) =>
GRANT_SCOPE_KEYWORDS.includes(id),
);
}
/**
* Helper for retrieving grant_scope_ids that are orgs.
* @type {string[]}
*/
get grantScopeOrgIDs() {
return this.grant_scope_ids.filter((id) => id.startsWith('o_'));
}
/**
* Helper for retrieving grant_scope_ids that are projects.
* @type {string[]}
*/
get grantScopeProjectIDs() {
return this.grant_scope_ids.filter((id) => id.startsWith('p_'));
}
/**
* A list of IDs for principals of type `user`.
* @type {string[]}
+60
View File
@@ -317,4 +317,64 @@ module('Unit | Model | role', function (hooks) {
assert.strictEqual(grantScopes[2].id, orgScope.id);
assert.strictEqual(grantScopes[3].id, projectScope.id);
});
test('grantScopeKeywords returns an array with only keywords in grant_scope_ids', async function (assert) {
const store = this.owner.lookup('service:store');
const orgScope = store.createRecord('scope', { id: 'o_123' });
const projectScope = store.createRecord('scope', { id: 'p_123' });
const globalScope = store.createRecord('scope', { id: 'global' });
const role = store.createRecord('role', {
grant_scope_ids: [
projectScope.id,
orgScope.id,
GRANT_SCOPE_THIS,
globalScope.id,
],
});
const grantScopeKeywords = role.grantScopeKeywords;
assert.strictEqual(grantScopeKeywords.length, 1);
assert.strictEqual(grantScopeKeywords[0], GRANT_SCOPE_THIS);
});
test('grantScopeOrgIDs returns an array with only org ids in grant_scope_ids', async function (assert) {
const store = this.owner.lookup('service:store');
const orgScope = store.createRecord('scope', { id: 'o_123' });
const projectScope = store.createRecord('scope', { id: 'p_123' });
const globalScope = store.createRecord('scope', { id: 'global' });
const role = store.createRecord('role', {
grant_scope_ids: [
projectScope.id,
orgScope.id,
GRANT_SCOPE_THIS,
globalScope.id,
],
});
const grantScopeOrgIDs = role.grantScopeOrgIDs;
assert.strictEqual(grantScopeOrgIDs.length, 1);
assert.strictEqual(grantScopeOrgIDs[0], orgScope.id);
});
test('grantScopeProjectIDs returns an array with only project ids in grant_scope_ids', async function (assert) {
const store = this.owner.lookup('service:store');
const orgScope = store.createRecord('scope', { id: 'o_123' });
const projectScope = store.createRecord('scope', { id: 'p_123' });
const globalScope = store.createRecord('scope', { id: 'global' });
const role = store.createRecord('role', {
grant_scope_ids: [
projectScope.id,
orgScope.id,
GRANT_SCOPE_THIS,
globalScope.id,
],
});
const grantScopeProjectIDs = role.grantScopeProjectIDs;
assert.strictEqual(grantScopeProjectIDs.length, 1);
assert.strictEqual(grantScopeProjectIDs[0], projectScope.id);
});
});
@@ -570,6 +570,8 @@ role:
titles:
parent_scope: Parent scope
resource_type: Resource type
projects_selected: Projects selected
selected: '{items} selected out of {total}'
messages:
none:
title: No scopes added
@@ -581,6 +583,12 @@ role:
keywords-selected:
description: Just a heads up, you can't select both add all descendants and add all children. You can only choose one or the other.
link: You can read more about it here
manage-custom-scopes:
title: Manage Custom Scopes
description: Select which specific orgs you want to apply to this role's grant sets.
no-org-scopes:
title: No Org Scopes Available
description: Org scopes are not available for assignment.
keywords:
this:
sub-title: This scope
@@ -7,7 +7,8 @@
...attributes
class='rose-header-brand'
title={{@text}}
@route={{if @route @route 'index'}}
@route={{if @route @route 'scopes.scope'}}
@model={{if @model @model 'global'}}
>
{{#if @logo}}
@@ -0,0 +1,109 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
{{#if @model.totalItemsCount}}
<Rose::Form
class='full-width role-manage-custom-scopes-form'
@onSubmit={{fn @submit this.allGrantScopes}}
@cancel={{@cancel}}
@disabled={{@model.role.isSaving}}
as |form|
>
<Hds::SegmentedGroup as |S|>
<S.TextInput
@value={{@search}}
@type='search'
placeholder={{t 'actions.search'}}
aria-label={{t 'actions.search'}}
{{on 'input' @handleSearchInput}}
/>
</Hds::SegmentedGroup>
{{#if @model.subScopes}}
{{#if this.selectedItems.length}}
<div class='table-selected-items-text'>
<Hds::Text::Body @tag='p' @color='faint'>
{{t
'resources.role.scope.titles.selected'
items=this.selectedItems.length
total=@model.totalItemsCount
}}
</Hds::Text::Body>
</div>
{{/if}}
<Hds::Table
@columns={{array
(hash label=(t 'resources.role.scope.title'))
(hash label=(t 'form.id.label'))
(hash label=(t 'resources.role.scope.titles.projects_selected'))
}}
@model={{@model.subScopes}}
@isSelectable={{true}}
@onSelectionChange={{this.selectionChange}}
@valign='middle'
>
<:body as |B|>
<B.Tr
@selectionKey={{B.data.id}}
@isSelected={{includes B.data.id this.selectedItems}}
@selectionAriaLabelSuffix='row {{B.data.id}}'
data-test-org-scopes-table-row={{B.data.id}}
>
<B.Td>
<Hds::Text::Body @tag='p'>
{{B.data.displayName}}
</Hds::Text::Body>
</B.Td>
<B.Td>
<Hds::Copy::Snippet
@textToCopy={{B.data.id}}
@color='secondary'
/>
</B.Td>
<B.Td>
{{! TODO: Add link to projects selected page}}
</B.Td>
</B.Tr>
</:body>
</Hds::Table>
<Rose::Pagination
@totalItems={{@model.totalItems}}
@currentPage={{@page}}
@currentPageSize={{@pageSize}}
/>
<form.actions
@submitText={{t 'actions.save'}}
@cancelText={{t 'actions.cancel'}}
/>
{{else}}
<Hds::ApplicationState data-test-no-scope-results as |A|>
<A.Header @title={{t 'titles.no-results-found'}} />
<A.Body
@text={{t
'descriptions.no-search-results'
query=@search
resource=(t 'resources.scope.title_plural')
}}
/>
</Hds::ApplicationState>
{{/if}}
</Rose::Form>
{{else}}
<Hds::ApplicationState as |A|>
<A.Header
@title={{t 'resources.role.scope.messages.no-org-scopes.title'}}
/>
<A.Body
@text={{t 'resources.role.scope.messages.no-org-scopes.description'}}
/>
<A.Footer as |F|>
<F.LinkStandalone
@icon='arrow-left'
@text={{t 'actions.back'}}
@route='scopes.scope.roles.role.manage-scopes'
/>
</A.Footer>
</Hds::ApplicationState>
{{/if}}
@@ -0,0 +1,43 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
export default class FormRoleManageCustomScopesIndexComponent extends Component {
// =attributes
@tracked selectedItems = [...this.args.model.role.grantScopeOrgIDs];
get allGrantScopes() {
return [
...this.args.model.role.grantScopeKeywords,
...this.args.model.role.grantScopeProjectIDs,
...this.selectedItems,
];
}
// =actions
/**
* Handles the selection changes for the paginated table.
* @param {object} selectableRowsStates
*/
@action
selectionChange({ selectableRowsStates }) {
selectableRowsStates.forEach((row) => {
const { isSelected, selectionKey: key } = row;
const includesId = this.selectedItems.includes(key);
if (isSelected) {
if (!includesId) this.selectedItems = [...this.selectedItems, key];
} else if (includesId) {
this.selectedItems = this.selectedItems.filter(
(item) => item !== key,
);
}
});
}
}
@@ -1,3 +1,8 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<Rose::Form
class='full-width role-manage-scopes-form'
@onSubmit={{fn @submit this.selectedItems}}
@@ -63,10 +68,15 @@
{{/if}}
{{! TODO: Add correct route string once route is created}}
{{! TODO: Add check-circle icon if custom scopes were added}}
<Hds::Button
@text={{t 'resources.role.scope.actions.add-custom-scopes.text'}}
@color='secondary'
@route='scopes.scope.roles.role'
@route={{if
@model.scope.isGlobal
'scopes.scope.roles.role.manage-scopes.manage-custom-scopes'
'scopes.scope.roles.role.manage-scopes'
}}
/>
<Hds::Form::HelperText
@@ -1,3 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
@@ -1,10 +1,15 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { loading } from 'ember-loading';
import { notifySuccess, notifyError } from 'core/decorators/notify';
export default class ScopesScopeRolesRoleManageScopesController extends Controller {
export default class ScopesScopeRolesRoleManageScopesIndexController extends Controller {
// =services
@service router;
@@ -22,7 +27,7 @@ export default class ScopesScopeRolesRoleManageScopesController extends Controll
@notifySuccess('notifications.add-success')
async setGrantScopes(role, grantScopeIDs) {
await role.setGrantScopes(grantScopeIDs);
this.router.replaceWith('scopes.scope.roles.role.scopes');
await this.router.replaceWith('scopes.scope.roles.role.scopes');
}
/**
@@ -0,0 +1,62 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { loading } from 'ember-loading';
import { tracked } from '@glimmer/tracking';
import { debounce } from 'core/decorators/debounce';
import { notifySuccess, notifyError } from 'core/decorators/notify';
export default class ScopesScopeRolesRoleManageScopesManageCustomScopesController extends Controller {
// =services
@service router;
// =attributes
queryParams = ['search', 'page', 'pageSize'];
@tracked search = '';
@tracked page = 1;
@tracked pageSize = 10;
// =actions
/**
* Handles input on each keystroke and the search queryParam
* @param {object} event
*/
@action
@debounce(250)
handleSearchInput(event) {
const { value } = event.target;
this.search = value;
this.page = 1;
}
/**
* Save grant scope IDs to current role via the API.
* @param {RoleModel} role
* @param {[string]} grantScopeIDs
*/
@action
@loading
@notifyError(({ message }) => message, { catch: true })
@notifySuccess('notifications.add-success')
async setGrantScopes(role, grantScopeIDs) {
await role.setGrantScopes(grantScopeIDs);
await this.router.replaceWith('scopes.scope.roles.role.manage-scopes');
}
/**
* Redirect to manage scopes as if nothing ever happened.
*/
@action
async cancel() {
await this.router.replaceWith('scopes.scope.roles.role.manage-scopes');
}
}
+3 -1
View File
@@ -53,7 +53,9 @@ Router.map(function () {
this.route('principals');
this.route('add-principals');
this.route('scopes');
this.route('manage-scopes');
this.route('manage-scopes', function () {
this.route('manage-custom-scopes');
});
});
this.route('new');
});
@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
export default class ScopesScopeRolesRoleManageScopesIndexRoute extends Route {}
@@ -0,0 +1,93 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class ScopesScopeRolesRoleManageScopesManageCustomScopesRoute extends Route {
// =attributes
queryParams = {
search: {
refreshModel: true,
replace: true,
},
page: {
refreshModel: true,
},
pageSize: {
refreshModel: true,
},
};
// =services
@service store;
// =methods
/**
* Loads sub scopes for the current scope.
* @returns {Promise<{role: RoleModel, subScopes: [ScopeModel], totalItems: number, totalItemsCount: number}> }
*/
async model({ search, page, pageSize }) {
const role = this.modelFor('scopes.scope.roles.role');
const currentScope = this.modelFor('scopes.scope');
const { id: scope_id } = currentScope;
const filters = {
scope_id: [{ equals: scope_id }],
};
const subScopes = await this.store.query('scope', {
scope_id,
query: { search, filters },
page,
pageSize,
});
const totalItems = subScopes.meta?.totalItems;
const totalItemsCount = await this.getTotalItemsCount(
scope_id,
search,
totalItems,
);
return {
role,
subScopes,
totalItems,
totalItemsCount,
};
}
/**
* Sets scopesExist to true if there exists any scopes and
* sets totalItemsCount to the number of scopes that exist.
* @param {string} scope_id
* @param {string} search
* @param {number} totalItems
* @returns {Promise<number>}
*/
async getTotalItemsCount(scope_id, search, totalItems) {
if (!search) {
return totalItems;
}
const options = { pushToStore: false, peekIndexedDB: true };
const scopes = await this.store.query(
'scope',
{
scope_id,
query: {
filters: {
scope_id: [{ equals: scope_id }],
},
},
page: 1,
pageSize: 1,
},
options,
);
return scopes.meta?.totalItems;
}
}
+12
View File
@@ -1054,3 +1054,15 @@
margin-bottom: 2rem;
}
}
.role-manage-custom-scopes-form {
.table-selected-items-text {
display: flex;
justify-content: end;
margin-bottom: 0.5rem;
}
.hds-pagination {
margin-bottom: 2rem;
}
}
@@ -9,26 +9,4 @@
@route='scopes.scope.roles.role.manage-scopes'
/>
<Rose::Layout::Page as |page|>
<page.breadcrumbs>
<Breadcrumbs::Container />
</page.breadcrumbs>
<page.header>
<h2>
{{t 'resources.role.scope.messages.manage-scopes.title'}}
{{! TODO: Add doclink }}
</h2>
<p>{{t 'resources.role.scope.messages.manage-scopes.description'}}</p>
</page.header>
<page.body>
<Form::Role::ManageScopes
@model={{@model}}
@submit={{fn this.setGrantScopes @model}}
@cancel={{this.cancel}}
/>
</page.body>
</Rose::Layout::Page>
{{outlet}}
@@ -0,0 +1,29 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<Rose::Layout::Page as |page|>
<page.breadcrumbs>
<Breadcrumbs::Container />
</page.breadcrumbs>
<page.header>
<h2>
{{t 'resources.role.scope.messages.manage-scopes.title'}}
{{! TODO: Add doclink }}
</h2>
<p>{{t 'resources.role.scope.messages.manage-scopes.description'}}</p>
</page.header>
<page.body>
<Form::Role::ManageScopes
@model={{@model}}
@showCheckIcon={{this.savedScopes}}
@submit={{fn this.setGrantScopes @model}}
@cancel={{this.cancel}}
/>
</page.body>
</Rose::Layout::Page>
@@ -0,0 +1,40 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
{{page-title (t 'resources.role.scope.messages.manage-custom-scopes.title')}}
<Breadcrumbs::Item
@text={{t 'resources.role.scope.messages.manage-custom-scopes.title'}}
@route='scopes.scope.roles.role.manage-scopes.manage-custom-scopes'
/>
<Rose::Layout::Page as |page|>
<page.breadcrumbs>
<Breadcrumbs::Container />
</page.breadcrumbs>
<page.header>
<h2>
{{t 'resources.role.scope.messages.manage-custom-scopes.title'}}
{{! TODO: Add doclink }}
</h2>
<p>{{t
'resources.role.scope.messages.manage-custom-scopes.description'
}}</p>
</page.header>
<page.body class='search-filtering'>
<Form::Role::ManageCustomScopes
@model={{@model}}
@search={{this.search}}
@page={{this.page}}
@pageSize={{this.pageSize}}
@handleSearchInput={{this.handleSearchInput}}
@submit={{fn this.setGrantScopes @model.role}}
@cancel={{this.cancel}}
/>
</page.body>
</Rose::Layout::Page>
@@ -153,7 +153,7 @@
<A.Body @text={{t 'resources.role.scope.messages.none.description'}} />
{{#if (can 'setGrantScopes role' @model)}}
<A.Footer as |F|>
<F.Link::Standalone
<F.LinkStandalone
@icon='plus-circle'
@text={{t 'resources.role.scope.messages.none.action'}}
@route='scopes.scope.roles.role.manage-scopes'
+214 -37
View File
@@ -4,13 +4,24 @@
*/
import { module, test } from 'qunit';
import { visit, currentURL, click, findAll } from '@ember/test-helpers';
import {
visit,
currentURL,
click,
findAll,
fillIn,
waitUntil,
} from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
import a11yAudit from 'ember-a11y-testing/test-support/audit';
import { authenticateSession } from 'ember-simple-auth/test-support';
import { GRANT_SCOPE_THIS } from 'api/models/role';
import {
GRANT_SCOPE_THIS,
GRANT_SCOPE_CHILDREN,
GRANT_SCOPE_DESCENDANTS,
} from 'api/models/role';
module('Acceptance | roles | scopes', function (hooks) {
setupApplicationTest(hooks);
@@ -19,25 +30,35 @@ module('Acceptance | roles | scopes', function (hooks) {
const SCOPE_TOGGLE_SELECTOR = (name) =>
`.hds-form-toggle input[name="${name}"]`;
const SCOPE_CHECKBOX_SELECTOR = (id) =>
`tbody [data-test-org-scopes-table-row="${id}"] input`;
const TABLE_ROW_SELECTOR = 'tbody tr';
const SAVE_BTN_SELECTOR = 'form [type="submit"]';
const CANCEL_BTN_SELECTOR = '.rose-form-actions [type="button"]';
const MANAGE_SCOPES_SELECTOR = '[data-test-manage-dropdown-scopes]';
const TOAST_SELECTOR = '[role="alert"]';
const SEARCH_INPUT_SELECTOR = '.search-filtering [type="search"]';
const NO_RESULTS_MSG_SELECTOR = '[data-test-no-scope-results]';
const instances = {
scopes: {
global: null,
org: null,
},
role: null,
globalRole: null,
orgRole: null,
};
const urls = {
orgScope: null,
roles: null,
role: null,
roleScopes: null,
manageScopes: null,
globalRoles: null,
globalRole: null,
globalRoleScopes: null,
globalManageScopes: null,
orgRoles: null,
orgRole: null,
orgRoleScopes: null,
orgManageScopes: null,
manageCustomScopes: null,
};
hooks.beforeEach(function () {
@@ -47,30 +68,43 @@ module('Acceptance | roles | scopes', function (hooks) {
type: 'org',
scope: { id: 'global', type: 'global' },
});
instances.role = this.server.create(
instances.globalRole = this.server.create(
'role',
{
scope: instances.scopes.global,
},
'withScopes',
);
instances.orgRole = this.server.create(
'role',
{
scope: instances.scopes.org,
},
'withScopes',
);
urls.roles = `/scopes/${instances.scopes.org.id}/roles`;
urls.role = `${urls.roles}/${instances.role.id}`;
urls.roleScopes = `${urls.role}/scopes`;
urls.manageScopes = `${urls.role}/manage-scopes`;
urls.globalRoles = `/scopes/global/roles`;
urls.globalRole = `${urls.globalRoles}/${instances.globalRole.id}`;
urls.globalRoleScopes = `${urls.globalRole}/scopes`;
urls.globalManageScopes = `${urls.globalRole}/manage-scopes`;
urls.manageCustomScopes = `${urls.globalManageScopes}/manage-custom-scopes`;
urls.orgRoles = `/scopes/${instances.scopes.org.id}/roles`;
urls.orgRole = `${urls.orgRoles}/${instances.orgRole.id}`;
urls.orgRoleScopes = `${urls.orgRole}/scopes`;
urls.orgManageScopes = `${urls.orgRole}/manage-scopes`;
});
test('visiting role scopes', async function (assert) {
await visit(urls.roleScopes);
await visit(urls.orgRoleScopes);
await a11yAudit();
assert.strictEqual(currentURL(), urls.roleScopes);
assert.strictEqual(currentURL(), urls.orgRoleScopes);
assert
.dom(TABLE_ROW_SELECTOR)
.exists({ count: instances.role.grant_scope_ids.length });
.exists({ count: instances.orgRole.grant_scope_ids.length });
});
test('user can naviage to scope from role grant scopes', async function (assert) {
await visit(urls.roleScopes);
await visit(urls.orgRoleScopes);
await click('tbody tr:nth-child(2) a');
assert.strictEqual(
currentURL(),
@@ -79,7 +113,7 @@ module('Acceptance | roles | scopes', function (hooks) {
});
test('user can naviage to parent scope from role grant scopes', async function (assert) {
await visit(urls.roleScopes);
await visit(urls.orgRoleScopes);
await click('tbody tr:nth-child(2) td:nth-child(3) a');
assert.strictEqual(
currentURL(),
@@ -88,34 +122,34 @@ module('Acceptance | roles | scopes', function (hooks) {
});
test('user sees no scopes message and action when role has no grant scopes', async function (assert) {
instances.role.update({ grant_scope_ids: [] });
await visit(urls.roleScopes);
instances.orgRole.update({ grant_scope_ids: [] });
await visit(urls.orgRoleScopes);
assert.dom('.role-grant-scopes div').includesText('No scopes added');
assert.dom('.role-grant-scopes div div:nth-child(3) a').isVisible();
});
test('user does not see action to add scopes when role has no grant scopes without proper permissions', async function (assert) {
instances.role.update({
instances.orgRole.update({
grant_scope_ids: [],
authorized_actions: instances.role.authorized_actions.filter(
authorized_actions: instances.orgRole.authorized_actions.filter(
(action) => action !== 'set-grant-scopes',
),
});
await visit(urls.roleScopes);
await visit(urls.orgRoleScopes);
assert.dom('.role-grant-scopes div div:nth-child(3) a').doesNotExist();
});
test('toggle and save scope keywords to add', async function (assert) {
instances.role.update({ grant_scope_ids: [] });
await visit(urls.role);
test('user can toggle and save scope keywords to add on manage scopes page', async function (assert) {
instances.orgRole.update({ grant_scope_ids: [] });
await visit(urls.orgRole);
await click(`[href="${urls.roleScopes}"]`);
await click(`[href="${urls.orgRoleScopes}"]`);
assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0);
await click(MANAGE_SCOPES_SELECTOR);
assert.strictEqual(currentURL(), urls.manageScopes);
assert.strictEqual(currentURL(), urls.orgManageScopes);
// Click three times to select, unselect, then reselect (for coverage)
await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS));
@@ -123,21 +157,43 @@ module('Acceptance | roles | scopes', function (hooks) {
await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS));
await click(SAVE_BTN_SELECTOR);
assert.strictEqual(currentURL(), urls.roleScopes);
assert.strictEqual(currentURL(), urls.orgRoleScopes);
assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 1);
});
test('toggle and cancel scope keywords to add', async function (assert) {
instances.role.update({ grant_scope_ids: [] });
await visit(urls.role);
test('correct toggles are visible for global level role on manage scopes page', async function (assert) {
await visit(urls.globalRole);
await click(`[href="${urls.roleScopes}"]`);
await click(MANAGE_SCOPES_SELECTOR);
assert.strictEqual(currentURL(), urls.globalManageScopes);
assert.dom(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)).isVisible();
assert.dom(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_CHILDREN)).isVisible();
assert.dom(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_DESCENDANTS)).isVisible();
});
test('correct toggles are visible for org level role on manage scopes page', async function (assert) {
await visit(urls.orgRole);
await click(MANAGE_SCOPES_SELECTOR);
assert.strictEqual(currentURL(), urls.orgManageScopes);
assert.dom(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)).isVisible();
assert.dom(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_CHILDREN)).isVisible();
assert.dom(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_DESCENDANTS)).doesNotExist();
});
test('user can toggle and cancel scope keywords to add on manage scopes page', async function (assert) {
instances.orgRole.update({ grant_scope_ids: [] });
await visit(urls.orgRole);
await click(`[href="${urls.orgRoleScopes}"]`);
assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0);
await click(MANAGE_SCOPES_SELECTOR);
assert.strictEqual(currentURL(), urls.manageScopes);
assert.strictEqual(currentURL(), urls.orgManageScopes);
// Click three times to select, unselect, then reselect (for coverage)
await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS));
@@ -145,11 +201,11 @@ module('Acceptance | roles | scopes', function (hooks) {
await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS));
await click(CANCEL_BTN_SELECTOR);
assert.strictEqual(currentURL(), urls.roleScopes);
assert.strictEqual(currentURL(), urls.orgRoleScopes);
assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0);
});
test('shows error message on scope save', async function (assert) {
test('shows error message on scope save on manage scopes page', async function (assert) {
this.server.post('/roles/:idMethod', () => {
return new Response(
400,
@@ -162,8 +218,8 @@ module('Acceptance | roles | scopes', function (hooks) {
},
);
});
instances.role.update({ grant_scope_ids: [] });
await visit(urls.role);
instances.orgRole.update({ grant_scope_ids: [] });
await visit(urls.orgRole);
await click(MANAGE_SCOPES_SELECTOR);
await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS));
@@ -171,4 +227,125 @@ module('Acceptance | roles | scopes', function (hooks) {
assert.dom(TOAST_SELECTOR).isVisible();
});
test('user can select and save custom scopes to add on manage custom scopes page', async function (assert) {
instances.globalRole.update({ grant_scope_ids: [] });
await visit(urls.globalRole);
await click(`[href="${urls.globalRoleScopes}"]`);
assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0);
await click(MANAGE_SCOPES_SELECTOR);
assert.strictEqual(currentURL(), urls.globalManageScopes);
await click(`[href="${urls.manageCustomScopes}"]`);
// Click three times to select, unselect, then reselect (for coverage)
await click(SCOPE_CHECKBOX_SELECTOR(instances.scopes.org.id));
await click(SCOPE_CHECKBOX_SELECTOR(instances.scopes.org.id));
await click(SCOPE_CHECKBOX_SELECTOR(instances.scopes.org.id));
await click(SAVE_BTN_SELECTOR);
assert.strictEqual(currentURL(), urls.globalManageScopes);
await click(SAVE_BTN_SELECTOR);
assert.strictEqual(currentURL(), urls.globalRoleScopes);
assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 1);
});
test('user can select and cancel custom scopes to add on manage custom scopes page', async function (assert) {
instances.globalRole.update({ grant_scope_ids: [] });
await visit(urls.globalRole);
await click(`[href="${urls.globalRoleScopes}"]`);
assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0);
await click(MANAGE_SCOPES_SELECTOR);
assert.strictEqual(currentURL(), urls.globalManageScopes);
await click(`[href="${urls.manageCustomScopes}"]`);
// Click three times to select, unselect, then reselect (for coverage)
await click(SCOPE_CHECKBOX_SELECTOR(instances.scopes.org.id));
await click(SCOPE_CHECKBOX_SELECTOR(instances.scopes.org.id));
await click(SCOPE_CHECKBOX_SELECTOR(instances.scopes.org.id));
await click(CANCEL_BTN_SELECTOR);
assert.strictEqual(currentURL(), urls.globalManageScopes);
await click(CANCEL_BTN_SELECTOR);
assert.strictEqual(currentURL(), urls.globalRoleScopes);
assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0);
});
test('shows error message on custom scope save on manage custom scopes page', async function (assert) {
this.server.post('/roles/:idMethod', () => {
return new Response(
400,
{},
{
status: 400,
code: 'invalid_argument',
message: 'The request was invalid.',
details: {},
},
);
});
instances.globalRole.update({ grant_scope_ids: [] });
await visit(urls.globalRole);
await click(MANAGE_SCOPES_SELECTOR);
await click(`[href="${urls.manageCustomScopes}"]`);
await click(SCOPE_CHECKBOX_SELECTOR(instances.scopes.org.id));
await click(SAVE_BTN_SELECTOR);
assert.dom(TOAST_SELECTOR).isVisible();
});
test('user can search for a specifc org scope by id on manage custom scopes page', async function (assert) {
const anotherOrg = this.server.create('scope', {
type: 'org',
scope: { id: 'global', type: 'global' },
});
await visit(urls.globalManageScopes);
await click(`[href="${urls.manageCustomScopes}"]`);
assert.dom(SCOPE_CHECKBOX_SELECTOR(instances.scopes.org.id)).exists();
assert.dom(SCOPE_CHECKBOX_SELECTOR(anotherOrg.id)).exists();
await fillIn(SEARCH_INPUT_SELECTOR, instances.scopes.org.id);
await waitUntil(
() => findAll(SCOPE_CHECKBOX_SELECTOR(anotherOrg.id)).length === 0,
);
assert.dom(SCOPE_CHECKBOX_SELECTOR(instances.scopes.org.id)).exists();
assert.dom(SCOPE_CHECKBOX_SELECTOR(anotherOrg.id)).doesNotExist();
});
test('user can search for org scopes and get no results on manage custom scopes page', async function (assert) {
const anotherOrg = this.server.create('scope', {
type: 'org',
scope: { id: 'global', type: 'global' },
});
await visit(urls.globalManageScopes);
await click(`[href="${urls.manageCustomScopes}"]`);
assert.dom(SCOPE_CHECKBOX_SELECTOR(instances.scopes.org.id)).exists();
assert.dom(SCOPE_CHECKBOX_SELECTOR(anotherOrg.id)).exists();
await fillIn(SEARCH_INPUT_SELECTOR, 'fake scope that does not exist');
await waitUntil(() => findAll(NO_RESULTS_MSG_SELECTOR).length === 1);
assert.dom(`[href="${urls.role1}"]`).doesNotExist();
assert.dom(`[href="${urls.role2}"]`).doesNotExist();
assert.dom(NO_RESULTS_MSG_SELECTOR).includesText('No results found');
});
});
@@ -1,3 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { visit, currentURL } from '@ember/test-helpers';
@@ -5,7 +10,7 @@ import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import { authenticateSession } from 'ember-simple-auth/test-support';
module(
'Unit | Controller | scopes/scope/roles/role/manage-scopes',
'Unit | Controller | scopes/scope/roles/role/manage-scopes/index',
function (hooks) {
setupTest(hooks);
setupMirage(hooks);
@@ -30,7 +35,7 @@ module(
authenticateSession({});
store = this.owner.lookup('service:store');
controller = this.owner.lookup(
'controller:scopes/scope/roles/role/manage-scopes',
'controller:scopes/scope/roles/role/manage-scopes/index',
);
instances.scopes.global = this.server.create('scope', { id: 'global' });
@@ -0,0 +1,91 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { visit, currentURL, waitUntil } from '@ember/test-helpers';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
import { authenticateSession } from 'ember-simple-auth/test-support';
module(
'Unit | Controller | scopes/scope/roles/role/manage-scopes/manage-custom-scopes',
function (hooks) {
setupTest(hooks);
setupMirage(hooks);
setupIndexedDb(hooks);
let store;
let controller;
const instances = {
scopes: {
global: null,
org: null,
},
role: null,
};
const urls = {
globalScope: null,
manageScopes: null,
manageCustomScopes: null,
};
hooks.beforeEach(async function () {
authenticateSession({});
store = this.owner.lookup('service:store');
controller = this.owner.lookup(
'controller:scopes/scope/roles/role/manage-scopes/manage-custom-scopes',
);
instances.scopes.global = this.server.create('scope', { id: 'global' });
instances.scopes.org = this.server.create('scope', {
type: 'org',
scope: { id: 'global', type: 'global' },
});
instances.role = this.server.create('role', {
scopeId: 'global',
grant_scope_ids: [],
});
urls.globalScope = `/scopes/global`;
urls.manageScopes = `${urls.globalScope}/roles/${instances.role.id}/manage-scopes`;
urls.manageCustomScopes = `${urls.manageScopes}/manage-custom-scopes`;
});
test('it exists', function (assert) {
assert.ok(controller);
});
test('handleSearchInput action sets expected values correctly', async function (assert) {
const searchValue = 'test';
controller.handleSearchInput({ target: { value: searchValue } });
await waitUntil(() => controller.search === searchValue);
assert.strictEqual(controller.page, 1);
assert.strictEqual(controller.search, searchValue);
});
test('setGrantScopes action adds scopes to specified model', async function (assert) {
await visit(urls.manageCustomScopes);
const role = await store.findRecord('role', instances.role.id);
assert.deepEqual(role.grant_scope_ids, []);
await controller.setGrantScopes(role, [instances.scopes.org.id]);
assert.deepEqual(role.grant_scope_ids, [instances.scopes.org.id]);
});
test('cancel action causes transition to expected route', async function (assert) {
await visit(urls.manageCustomScopes);
await controller.cancel();
assert.strictEqual(currentURL(), urls.manageScopes);
});
},
);
@@ -1,3 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
@@ -0,0 +1,21 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module(
'Unit | Route | scopes/scope/roles/role/manage-scopes/index',
function (hooks) {
setupTest(hooks);
test('it exists', function (assert) {
let route = this.owner.lookup(
'route:scopes/scope/roles/role/manage-scopes/index',
);
assert.ok(route);
});
},
);
@@ -0,0 +1,21 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module(
'Unit | Route | scopes/scope/roles/role/manage-scopes/manage-custom-scopes',
function (hooks) {
setupTest(hooks);
test('it exists', function (assert) {
let route = this.owner.lookup(
'route:scopes/scope/roles/role/manage-scopes/manage-custom-scopes',
);
assert.ok(route);
});
},
);