diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index ff864c25a..05d3c67f3 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -586,6 +586,9 @@ role: manage-custom-scopes: title: Manage Custom Scopes description: Select which specific orgs you want to apply to this role's grant sets. + manage-org-projects: + title: 'Add Projects in {orgDisplayName}' + description: Select which projects in this org 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. @@ -603,6 +606,7 @@ role: add-custom-scopes: text: Add custom scopes help: Customize which scopes are added to this role. + view-projects: '{selected}/{total} total projects' form: this: label: Add this scope diff --git a/addons/rose/addon/components/rose/header/brand/index.hbs b/addons/rose/addon/components/rose/header/brand/index.hbs index 3a4078c5e..88b5162d2 100644 --- a/addons/rose/addon/components/rose/header/brand/index.hbs +++ b/addons/rose/addon/components/rose/header/brand/index.hbs @@ -7,8 +7,7 @@ ...attributes class='rose-header-brand' title={{@text}} - @route={{if @route @route 'scopes.scope'}} - @model={{if @model @model 'global'}} + @route={{if @route @route 'index'}} > {{#if @logo}} diff --git a/addons/rose/addon/components/rose/pagination/index.hbs b/addons/rose/addon/components/rose/pagination/index.hbs index 9c66516d3..80f4144dc 100644 --- a/addons/rose/addon/components/rose/pagination/index.hbs +++ b/addons/rose/addon/components/rose/pagination/index.hbs @@ -8,5 +8,6 @@ @currentPage={{@currentPage}} @currentPageSize={{@currentPageSize}} @route={{this.router.currentRouteName}} + @model={{@model}} @queryFunction={{this.paginationQueryParams}} /> \ No newline at end of file diff --git a/ui/admin/app/components/form/role/manage-custom-scopes/index.hbs b/ui/admin/app/components/form/role/manage-custom-scopes/index.hbs index a36217400..b7cd2495c 100644 --- a/ui/admin/app/components/form/role/manage-custom-scopes/index.hbs +++ b/ui/admin/app/components/form/role/manage-custom-scopes/index.hbs @@ -20,7 +20,7 @@ {{on 'input' @handleSearchInput}} /> - {{#if @model.subScopes}} + {{#if @model.orgScopes}} {{#if this.selectedItems.length}}
@@ -38,7 +38,7 @@ (hash label=(t 'form.id.label')) (hash label=(t 'resources.role.scope.titles.projects_selected')) }} - @model={{@model.subScopes}} + @model={{@model.orgScopes}} @isSelectable={{true}} @onSelectionChange={{this.selectionChange}} @valign='middle' @@ -62,12 +62,27 @@ /> - {{! TODO: Add link to projects selected page}} + {{#let (get @model.projectTotals B.data.id) as |projectTotals|}} + {{#if projectTotals}} + + {{/if}} + {{/let}} {{else}} - + + + + + {{#if @model.projectScopes}} + {{#if this.selectedItems.length}} +
+ + {{t + 'resources.role.scope.titles.selected' + items=this.selectedItems.length + total=@model.totalItemsCount + }} + +
+ {{/if}} + + <:body as |B|> + + + + {{B.data.displayName}} + + + + + + + + + + + + {{else}} + + + + + {{/if}} + +{{else}} + + + + + + + +{{/if}} \ No newline at end of file diff --git a/ui/admin/app/components/form/role/manage-org-projects/index.js b/ui/admin/app/components/form/role/manage-org-projects/index.js new file mode 100644 index 000000000..2ef0c9d93 --- /dev/null +++ b/ui/admin/app/components/form/role/manage-org-projects/index.js @@ -0,0 +1,45 @@ +/** + * 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 FormRoleManageOrgProjectsIndexComponent extends Component { + // =attributes + + @tracked selectedItems = [...this.args.model.selectedProjectIDs]; + + get allGrantScopes() { + return [ + ...this.args.model.role.grantScopeKeywords, + ...this.args.model.role.grantScopeOrgIDs, + ...this.args.model.remainingProjectIDs, + ...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, + ); + } + }); + } +} diff --git a/ui/admin/app/components/form/role/manage-scopes/index.hbs b/ui/admin/app/components/form/role/manage-scopes/index.hbs index b5e7afdb3..2842cd267 100644 --- a/ui/admin/app/components/form/role/manage-scopes/index.hbs +++ b/ui/admin/app/components/form/role/manage-scopes/index.hbs @@ -75,8 +75,9 @@ @route={{if @model.scope.isGlobal 'scopes.scope.roles.role.manage-scopes.manage-custom-scopes' - 'scopes.scope.roles.role.manage-scopes' + 'scopes.scope.roles.role.manage-scopes.manage-org-projects' }} + @model={{if @model.scope.isOrg @model.scope.id}} /> message, { catch: true }) + @notifySuccess('notifications.add-success') + async setGrantScopes(role, grantScopeIDs) { + await role.setGrantScopes(grantScopeIDs); + if (role.scope.isGlobal) { + this.router.replaceWith( + 'scopes.scope.roles.role.manage-scopes.manage-custom-scopes', + ); + } else { + this.router.replaceWith('scopes.scope.roles.role.manage-scopes'); + } + } + + /** + * Redirect to previous as if nothing ever happened. + */ + @action + cancel(role) { + if (role.scope.isGlobal) { + this.router.replaceWith( + 'scopes.scope.roles.role.manage-scopes.manage-custom-scopes', + ); + } else { + this.router.replaceWith('scopes.scope.roles.role.manage-scopes'); + } + } +} diff --git a/ui/admin/app/router.js b/ui/admin/app/router.js index 960a1802e..ecac55a4e 100644 --- a/ui/admin/app/router.js +++ b/ui/admin/app/router.js @@ -55,6 +55,7 @@ Router.map(function () { this.route('scopes'); this.route('manage-scopes', function () { this.route('manage-custom-scopes'); + this.route('manage-org-projects', { path: ':org_id' }); }); }); this.route('new'); diff --git a/ui/admin/app/routes/scopes/scope/roles/role/index.js b/ui/admin/app/routes/scopes/scope/roles/role/index.js index 989e8def0..df3f4783f 100644 --- a/ui/admin/app/routes/scopes/scope/roles/role/index.js +++ b/ui/admin/app/routes/scopes/scope/roles/role/index.js @@ -4,5 +4,25 @@ */ import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; -export default class ScopesScopeRolesRoleIndexRoute extends Route {} +export default class ScopesScopeRolesRoleIndexRoute extends Route { + // =services + + @service store; + + // =methods + + /** + * Pre-loads all scopes since they will be necessary in sub-routes. + * @param {RoleModel} role + */ + async afterModel(role) { + if (!role.scope.isProject) { + await this.store.query('scope', { + scope_id: role.scope.id, + recursive: true, + }); + } + } +} diff --git a/ui/admin/app/routes/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.js b/ui/admin/app/routes/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.js index 070c975c8..807a7f271 100644 --- a/ui/admin/app/routes/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.js +++ b/ui/admin/app/routes/scopes/scope/roles/role/manage-scopes/manage-custom-scopes.js @@ -5,6 +5,7 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; +import { TYPE_SCOPE_PROJECT } from 'api/models/scope'; export default class ScopesScopeRolesRoleManageScopesManageCustomScopesRoute extends Route { // =attributes @@ -30,7 +31,7 @@ export default class ScopesScopeRolesRoleManageScopesManageCustomScopesRoute ext /** * Loads sub scopes for the current scope. - * @returns {Promise<{role: RoleModel, subScopes: [ScopeModel], totalItems: number, totalItemsCount: number}> } + * @returns {Promise<{role: RoleModel, orgScopes: [ScopeModel], projectTotals: object, totalItems: number, totalItemsCount: number}> } */ async model({ search, page, pageSize }) { const role = this.modelFor('scopes.scope.roles.role'); @@ -40,27 +41,63 @@ export default class ScopesScopeRolesRoleManageScopesManageCustomScopesRoute ext scope_id: [{ equals: scope_id }], }; - const subScopes = await this.store.query('scope', { + const orgScopes = await this.store.query('scope', { scope_id, query: { search, filters }, page, pageSize, }); - const totalItems = subScopes.meta?.totalItems; + const totalItems = orgScopes.meta?.totalItems; const totalItemsCount = await this.getTotalItemsCount( scope_id, search, totalItems, ); + const projectTotals = await this.getProjectTotals( + role.grantScopeProjectIDs, + ); + return { role, - subScopes, + orgScopes, + projectTotals, totalItems, totalItemsCount, }; } + /** + * Creates an object that contains the number of selected projects + * and the total number of projects for each org scope. + * @param {[string]} projectIDs + * @returns {object} + */ + async getProjectTotals(projectIDs) { + const options = { pushToStore: false, peekIndexedDB: true }; + const projects = await this.store.query( + 'scope', + { + scope_id: 'global', + query: { filters: { type: [{ equals: TYPE_SCOPE_PROJECT }] } }, + }, + options, + ); + + const projectTotals = {}; + projects.forEach(({ id, scope }) => { + if (!projectTotals[scope.id]) { + projectTotals[scope.id] = { selected: 0, total: 0 }; + } + if (projectIDs.includes(id)) { + projectTotals[scope.id].selected++; + } + projectTotals[scope.id].total++; + }); + + return projectTotals; + } + /** * Sets scopesExist to true if there exists any scopes and * sets totalItemsCount to the number of scopes that exist. diff --git a/ui/admin/app/routes/scopes/scope/roles/role/manage-scopes/manage-org-projects.js b/ui/admin/app/routes/scopes/scope/roles/role/manage-scopes/manage-org-projects.js new file mode 100644 index 000000000..82f38b0b8 --- /dev/null +++ b/ui/admin/app/routes/scopes/scope/roles/role/manage-scopes/manage-org-projects.js @@ -0,0 +1,128 @@ +/** + * 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 ScopesScopeRolesRoleManageScopesManageOrgProjectsRoute extends Route { + // =attributes + + queryParams = { + search: { + refreshModel: true, + replace: true, + }, + page: { + refreshModel: true, + }, + pageSize: { + refreshModel: true, + }, + }; + + // =services + + @service store; + + // =methods + + /** + * Loads projects for current org scope. + * @return {Promise<{role: RoleModel, orgScope: ScopeModel, projectScopes: [ScopeModel], totalItems: number, totalItemsCount: number, selectedProjectIDs: [string], remainingProjectIDs: [string]}> } + */ + async model({ org_id, search, page, pageSize }) { + const role = this.modelFor('scopes.scope.roles.role'); + const orgScope = this.store.peekRecord('scope', org_id); + const filters = { + scope_id: [{ equals: org_id }], + }; + + const projectScopes = await this.store.query('scope', { + scope_id: org_id, + query: { search, filters }, + page, + pageSize, + }); + const totalItems = projectScopes.meta?.totalItems; + const totalItemsCount = await this.getTotalItemsCount( + org_id, + search, + totalItems, + ); + + const { selectedProjectIDs, remainingProjectIDs } = + await this.getSelectedProjects(role, org_id); + + return { + role, + orgScope, + projectScopes, + totalItems, + totalItemsCount, + selectedProjectIDs, + remainingProjectIDs, + }; + } + + /** + * 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} + */ + 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; + } + + /** + * Extract project type grant scope ids that belong to this org scope. + * @param {RoleModel} role + * @param {string} org_id + * @returns {object} + */ + async getSelectedProjects(role, org_id) { + let selectedProjectIDs = role.grantScopeProjectIDs; + let remainingProjectIDs = []; + if (role.scope.isGlobal && selectedProjectIDs.length) { + const id = []; + selectedProjectIDs.forEach((projectID) => id.push({ equals: projectID })); + const options = { pushToStore: false, peekIndexedDB: true }; + const projects = await this.store.query( + 'scope', + { scope_id: 'global', query: { filters: { id } } }, + options, + ); + selectedProjectIDs = projects.reduce((selectedIDs, project) => { + if (project.scope.id === org_id) { + selectedIDs.push(project.id); + } else { + remainingProjectIDs.push(project.id); + } + return selectedIDs; + }, []); + } + return { selectedProjectIDs, remainingProjectIDs }; + } +} diff --git a/ui/admin/app/routes/scopes/scope/roles/role/scopes.js b/ui/admin/app/routes/scopes/scope/roles/role/scopes.js index bf43ea083..bff216285 100644 --- a/ui/admin/app/routes/scopes/scope/roles/role/scopes.js +++ b/ui/admin/app/routes/scopes/scope/roles/role/scopes.js @@ -1,23 +1,9 @@ import Route from '@ember/routing/route'; -import { inject as service } from '@ember/service'; -import { GRANT_SCOPE_KEYWORDS } from 'api/models/role'; export default class ScopesScopeRolesRoleScopesRoute extends Route { - // =services + // =methods - @service store; - - async model() { - const role = this.modelFor('scopes.scope.roles.role'); - if ( - role.grant_scope_ids && - !GRANT_SCOPE_KEYWORDS.some((id) => role.grant_scope_ids.includes(id)) - ) { - await this.store.query('scope', { - scope_id: role.scope.id, - recursive: true, - }); - } - return role; + model() { + return this.modelFor('scopes.scope.roles.role'); } } diff --git a/ui/admin/app/styles/app.scss b/ui/admin/app/styles/app.scss index 88fb5deaa..7bf29b5c2 100644 --- a/ui/admin/app/styles/app.scss +++ b/ui/admin/app/styles/app.scss @@ -1059,7 +1059,10 @@ .table-selected-items-text { display: flex; justify-content: end; - margin-bottom: 0.5rem; + } + + .hds-table { + margin-top: 0.5rem; } .hds-pagination { diff --git a/ui/admin/app/templates/scopes/scope/roles/role/manage-scopes/manage-org-projects.hbs b/ui/admin/app/templates/scopes/scope/roles/role/manage-scopes/manage-org-projects.hbs new file mode 100644 index 000000000..39cd0d146 --- /dev/null +++ b/ui/admin/app/templates/scopes/scope/roles/role/manage-scopes/manage-org-projects.hbs @@ -0,0 +1,56 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + +{{page-title + (t + 'resources.role.scope.messages.manage-org-projects.title' + orgDisplayName=@model.orgScope.displayName + ) +}} +{{#if @model.role.scope.isGlobal}} + +{{/if}} + + + + + + + + + +

+ {{t + 'resources.role.scope.messages.manage-org-projects.title' + orgDisplayName=@model.orgScope.displayName + }} + {{! TODO: Add doclink }} +

+

{{t 'resources.role.scope.messages.manage-org-projects.description'}}

+
+ + + + + +
\ No newline at end of file diff --git a/ui/admin/tests/acceptance/roles/global-scope-test.js b/ui/admin/tests/acceptance/roles/global-scope-test.js new file mode 100644 index 000000000..cad512abf --- /dev/null +++ b/ui/admin/tests/acceptance/roles/global-scope-test.js @@ -0,0 +1,533 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +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, + GRANT_SCOPE_CHILDREN, + GRANT_SCOPE_DESCENDANTS, +} from 'api/models/role'; + +module('Acceptance | roles | global-scope', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + setupIndexedDb(hooks); + + const SCOPE_TOGGLE_SELECTOR = (name) => + `.hds-form-toggle input[name="${name}"]`; + const SCOPE_CHECKBOX_SELECTOR = (type, id) => + `tbody [data-test-${type}-scopes-table-row="${id}"] input`; + const TABLE_ROW_SELECTOR = 'tbody tr'; + const TABLE_SCOPE_SELECTOR = 'tbody tr:nth-child(2) a'; + const TABLE_PARENT_SCOPE_SELECTOR = 'tbody tr:nth-child(2) td:nth-child(3) a'; + 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-grant-scope-results]'; + const NO_SCOPES_MSG_SELECTOR = '.role-grant-scopes div'; + const NO_SCOPES_MSG_LINK_SELECTOR = + '.role-grant-scopes div div:nth-child(3) a'; + + const instances = { + scopes: { + global: null, + org: null, + project: null, + }, + role: null, + }; + const urls = { + orgScope: null, + roles: null, + role: null, + roleScopes: null, + manageScopes: null, + manageCustomScopes: null, + }; + + hooks.beforeEach(function () { + authenticateSession({}); + instances.scopes.global = this.server.create('scope', { id: 'global' }); + instances.scopes.org = this.server.create('scope', { + type: 'org', + scope: { id: 'global', type: 'global' }, + }); + instances.scopes.project = this.server.create('scope', { + type: 'project', + scope: { id: instances.scopes.org.id, type: 'org' }, + }); + instances.role = this.server.create( + 'role', + { + scope: instances.scopes.global, + }, + 'withScopes', + ); + + urls.roles = `/scopes/global/roles`; + urls.role = `${urls.roles}/${instances.role.id}`; + urls.roleScopes = `${urls.role}/scopes`; + urls.manageScopes = `${urls.role}/manage-scopes`; + urls.manageCustomScopes = `${urls.manageScopes}/manage-custom-scopes`; + }); + + test('visiting role scopes', async function (assert) { + await visit(urls.role); + + await click(`[href="${urls.roleScopes}"]`); + await a11yAudit(); + + assert.strictEqual(currentURL(), urls.roleScopes); + assert + .dom(TABLE_ROW_SELECTOR) + .exists({ count: instances.role.grant_scope_ids.length }); + }); + + test('user can naviage to scope from role grant scopes', async function (assert) { + await visit(urls.role); + + await click(`[href="${urls.roleScopes}"]`); + await click(TABLE_SCOPE_SELECTOR); + + assert.strictEqual( + currentURL(), + `/scopes/${instances.scopes.global.id}/scopes`, + ); + }); + + test('user can naviage to parent scope from role grant scopes', async function (assert) { + instances.role.update({ + grant_scope_ids: ['this', instances.scopes.org.id], + }); + await visit(urls.role); + + await click(`[href="${urls.roleScopes}"]`); + await click(TABLE_PARENT_SCOPE_SELECTOR); + + assert.strictEqual( + currentURL(), + `/scopes/${instances.scopes.global.id}/scopes`, + ); + }); + + 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.role); + + await click(`[href="${urls.roleScopes}"]`); + + assert.dom(NO_SCOPES_MSG_SELECTOR).includesText('No scopes added'); + assert.dom(NO_SCOPES_MSG_LINK_SELECTOR).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({ + grant_scope_ids: [], + authorized_actions: instances.role.authorized_actions.filter( + (action) => action !== 'set-grant-scopes', + ), + }); + await visit(urls.role); + + await click(`[href="${urls.roleScopes}"]`); + + assert.dom(NO_SCOPES_MSG_LINK_SELECTOR).doesNotExist(); + }); + + test('correct toggles are visible for global level role on manage scopes page', async function (assert) { + await visit(urls.role); + + await click(MANAGE_SCOPES_SELECTOR); + + assert.strictEqual(currentURL(), urls.manageScopes); + 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('user can save scope keywords to add on manage scopes page', async function (assert) { + instances.role.update({ grant_scope_ids: [] }); + await visit(urls.role); + + await click(`[href="${urls.roleScopes}"]`); + + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0); + + await click(MANAGE_SCOPES_SELECTOR); + await a11yAudit(); + + assert.strictEqual(currentURL(), urls.manageScopes); + + // Click three times to select, unselect, then reselect (for coverage) + await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); + await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); + await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); + await click(SAVE_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.roleScopes); + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 1); + }); + + test('user can cancel scope keywords to add on manage scopes page', async function (assert) { + instances.role.update({ grant_scope_ids: [] }); + await visit(urls.role); + + await click(`[href="${urls.roleScopes}"]`); + + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0); + + await click(MANAGE_SCOPES_SELECTOR); + + assert.strictEqual(currentURL(), urls.manageScopes); + + // Click three times to select, unselect, then reselect (for coverage) + await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); + await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); + await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); + await click(CANCEL_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.roleScopes); + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0); + }); + + test('shows error message on scope save on manage 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.role.update({ grant_scope_ids: [] }); + await visit(urls.role); + + await click(MANAGE_SCOPES_SELECTOR); + await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); + await click(SAVE_BTN_SELECTOR); + + assert.dom(TOAST_SELECTOR).isVisible(); + }); + + test('user can save custom scopes to add on manage custom scopes page', async function (assert) { + instances.role.update({ grant_scope_ids: [] }); + await visit(urls.role); + + await click(`[href="${urls.roleScopes}"]`); + + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0); + + await click(MANAGE_SCOPES_SELECTOR); + + assert.strictEqual(currentURL(), urls.manageScopes); + + await click(`[href="${urls.manageCustomScopes}"]`); + await a11yAudit(); + + assert.strictEqual(currentURL(), urls.manageCustomScopes); + + // Click three times to select, unselect, then reselect (for coverage) + await click(SCOPE_CHECKBOX_SELECTOR('org', instances.scopes.org.id)); + await click(SCOPE_CHECKBOX_SELECTOR('org', instances.scopes.org.id)); + await click(SCOPE_CHECKBOX_SELECTOR('org', instances.scopes.org.id)); + await click(SAVE_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.manageScopes); + + await click(SAVE_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.roleScopes); + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 1); + }); + + test('user can cancel custom scopes to add on manage custom scopes page', async function (assert) { + instances.role.update({ grant_scope_ids: [] }); + await visit(urls.role); + + await click(`[href="${urls.roleScopes}"]`); + + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0); + + await click(MANAGE_SCOPES_SELECTOR); + + assert.strictEqual(currentURL(), urls.manageScopes); + + await click(`[href="${urls.manageCustomScopes}"]`); + + // Click three times to select, unselect, then reselect (for coverage) + await click(SCOPE_CHECKBOX_SELECTOR('org', instances.scopes.org.id)); + await click(SCOPE_CHECKBOX_SELECTOR('org', instances.scopes.org.id)); + await click(SCOPE_CHECKBOX_SELECTOR('org', instances.scopes.org.id)); + await click(CANCEL_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.manageScopes); + + await click(CANCEL_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.roleScopes); + 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.role.update({ grant_scope_ids: [] }); + await visit(urls.role); + + await click(MANAGE_SCOPES_SELECTOR); + await click(`[href="${urls.manageCustomScopes}"]`); + await click(SCOPE_CHECKBOX_SELECTOR('org', 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.manageScopes); + + await click(`[href="${urls.manageCustomScopes}"]`); + + assert + .dom(SCOPE_CHECKBOX_SELECTOR('org', instances.scopes.org.id)) + .exists(); + assert.dom(SCOPE_CHECKBOX_SELECTOR('org', anotherOrg.id)).exists(); + + await fillIn(SEARCH_INPUT_SELECTOR, instances.scopes.org.id); + await waitUntil( + () => findAll(SCOPE_CHECKBOX_SELECTOR('org', anotherOrg.id)).length === 0, + ); + + assert + .dom(SCOPE_CHECKBOX_SELECTOR('org', instances.scopes.org.id)) + .exists(); + assert.dom(SCOPE_CHECKBOX_SELECTOR('org', 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.manageScopes); + + await click(`[href="${urls.manageCustomScopes}"]`); + + assert + .dom(SCOPE_CHECKBOX_SELECTOR('org', instances.scopes.org.id)) + .exists(); + assert.dom(SCOPE_CHECKBOX_SELECTOR('org', 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(SCOPE_CHECKBOX_SELECTOR('org', instances.scopes.org.id)) + .doesNotExist(); + assert.dom(SCOPE_CHECKBOX_SELECTOR('org', anotherOrg.id)).doesNotExist(); + assert.dom(NO_RESULTS_MSG_SELECTOR).includesText('No results found'); + }); + + test('user can save custom scopes to add on manage org projects page', async function (assert) { + instances.role.update({ grant_scope_ids: [] }); + await visit(urls.role); + + await click(`[href="${urls.roleScopes}"]`); + + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0); + + await click(MANAGE_SCOPES_SELECTOR); + await click(`[href="${urls.manageCustomScopes}"]`); + await click( + `tbody [href="${urls.manageScopes}/${instances.scopes.org.id}"]`, + ); + await a11yAudit(); + + // Click three times to select, unselect, then reselect (for coverage) + await click( + SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id), + ); + await click( + SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id), + ); + await click( + SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id), + ); + await click(SAVE_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.manageCustomScopes); + + await click(SAVE_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.manageScopes); + + await click(SAVE_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.roleScopes); + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 1); + }); + + test('user can cancel custom scopes to add on manage org projects page', async function (assert) { + instances.role.update({ grant_scope_ids: [] }); + await visit(urls.role); + + await click(`[href="${urls.roleScopes}"]`); + + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0); + + await click(MANAGE_SCOPES_SELECTOR); + await click(`[href="${urls.manageCustomScopes}"]`); + await click( + `tbody [href="${urls.manageScopes}/${instances.scopes.org.id}"]`, + ); + + // Click three times to select, unselect, then reselect (for coverage) + await click( + SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id), + ); + await click( + SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id), + ); + await click( + SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id), + ); + await click(CANCEL_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.manageCustomScopes); + + await click(CANCEL_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.manageScopes); + + await click(CANCEL_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.roleScopes); + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0); + }); + + test('shows error message on custom scope save on manage org projects 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.role.update({ grant_scope_ids: [] }); + await visit(urls.role); + + await click(MANAGE_SCOPES_SELECTOR); + await click(`[href="${urls.manageCustomScopes}"]`); + await click( + `tbody [href="${urls.manageScopes}/${instances.scopes.org.id}"]`, + ); + await click( + SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id), + ); + await click(SAVE_BTN_SELECTOR); + + assert.dom(TOAST_SELECTOR).isVisible(); + }); + + test('user can search for a specifc project scope by id on manage org projects page', async function (assert) { + const anotherProject = this.server.create('scope', { + type: 'project', + scope: { id: instances.scopes.org.id, type: 'org' }, + }); + await visit(urls.role); + + await click(MANAGE_SCOPES_SELECTOR); + await click(`[href="${urls.manageCustomScopes}"]`); + await click( + `tbody [href="${urls.manageScopes}/${instances.scopes.org.id}"]`, + ); + + assert + .dom(SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id)) + .exists(); + assert.dom(SCOPE_CHECKBOX_SELECTOR('project', anotherProject.id)).exists(); + + await fillIn(SEARCH_INPUT_SELECTOR, instances.scopes.project.id); + await waitUntil( + () => + findAll(SCOPE_CHECKBOX_SELECTOR('project', anotherProject.id)) + .length === 0, + ); + + assert + .dom(SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id)) + .exists(); + assert + .dom(SCOPE_CHECKBOX_SELECTOR('project', anotherProject.id)) + .doesNotExist(); + }); + + test('user can search for project scopes and get no results on manage org projects page', async function (assert) { + const anotherProject = this.server.create('scope', { + type: 'project', + scope: { id: instances.scopes.org.id, type: 'org' }, + }); + await visit(urls.role); + + await click(MANAGE_SCOPES_SELECTOR); + await click(`[href="${urls.manageCustomScopes}"]`); + await click( + `tbody [href="${urls.manageScopes}/${instances.scopes.org.id}"]`, + ); + + assert + .dom(SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id)) + .exists(); + assert.dom(SCOPE_CHECKBOX_SELECTOR('project', anotherProject.id)).exists(); + + await fillIn(SEARCH_INPUT_SELECTOR, 'fake scope that does not exist'); + await waitUntil(() => findAll(NO_RESULTS_MSG_SELECTOR).length === 1); + + assert + .dom(SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id)) + .doesNotExist(); + assert + .dom(SCOPE_CHECKBOX_SELECTOR('project', anotherProject.id)) + .doesNotExist(); + assert.dom(NO_RESULTS_MSG_SELECTOR).includesText('No results found'); + }); +}); diff --git a/ui/admin/tests/acceptance/roles/org-scope-test.js b/ui/admin/tests/acceptance/roles/org-scope-test.js new file mode 100644 index 000000000..c863cbd8a --- /dev/null +++ b/ui/admin/tests/acceptance/roles/org-scope-test.js @@ -0,0 +1,376 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +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, + GRANT_SCOPE_CHILDREN, + GRANT_SCOPE_DESCENDANTS, +} from 'api/models/role'; + +module('Acceptance | roles | org-scope', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + setupIndexedDb(hooks); + + const SCOPE_TOGGLE_SELECTOR = (name) => + `.hds-form-toggle input[name="${name}"]`; + const SCOPE_CHECKBOX_SELECTOR = (type, id) => + `tbody [data-test-${type}-scopes-table-row="${id}"] input`; + const TABLE_ROW_SELECTOR = 'tbody tr'; + const TABLE_SCOPE_SELECTOR = 'tbody tr:nth-child(2) a'; + const TABLE_PARENT_SCOPE_SELECTOR = 'tbody tr:nth-child(2) td:nth-child(3) a'; + 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-grant-scope-results]'; + const NO_SCOPES_MSG_SELECTOR = '.role-grant-scopes div'; + const NO_SCOPES_MSG_LINK_SELECTOR = + '.role-grant-scopes div div:nth-child(3) a'; + + const instances = { + scopes: { + global: null, + org: null, + project: null, + }, + role: null, + }; + const urls = { + orgScope: null, + roles: null, + role: null, + roleScopes: null, + manageScopes: null, + manageOrgProjects: null, + }; + + hooks.beforeEach(function () { + authenticateSession({}); + instances.scopes.global = this.server.create('scope', { id: 'global' }); + instances.scopes.org = this.server.create('scope', { + type: 'org', + scope: { id: 'global', type: 'global' }, + }); + instances.scopes.project = this.server.create('scope', { + type: 'project', + scope: { id: instances.scopes.org.id, type: 'org' }, + }); + instances.role = 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.manageOrgProjects = `${urls.role}/manage-scopes/${instances.scopes.org.id}`; + }); + + test('visiting role scopes', async function (assert) { + await visit(urls.role); + + await click(`[href="${urls.roleScopes}"]`); + await a11yAudit(); + + assert.strictEqual(currentURL(), urls.roleScopes); + assert + .dom(TABLE_ROW_SELECTOR) + .exists({ count: instances.role.grant_scope_ids.length }); + }); + + test('user can naviage to scope from role grant scopes', async function (assert) { + await visit(urls.role); + + await click(`[href="${urls.roleScopes}"]`); + await click(TABLE_SCOPE_SELECTOR); + + assert.strictEqual( + currentURL(), + `/scopes/${instances.scopes.org.id}/scopes`, + ); + }); + + test('user can naviage to parent scope from role grant scopes', async function (assert) { + await visit(urls.role); + + await click(`[href="${urls.roleScopes}"]`); + await click(TABLE_PARENT_SCOPE_SELECTOR); + + assert.strictEqual( + currentURL(), + `/scopes/${instances.scopes.global.id}/scopes`, + ); + }); + + 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.role); + + await click(`[href="${urls.roleScopes}"]`); + + assert.dom(NO_SCOPES_MSG_SELECTOR).includesText('No scopes added'); + assert.dom(NO_SCOPES_MSG_LINK_SELECTOR).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({ + grant_scope_ids: [], + authorized_actions: instances.role.authorized_actions.filter( + (action) => action !== 'set-grant-scopes', + ), + }); + await visit(urls.role); + + await click(`[href="${urls.roleScopes}"]`); + + assert.dom(NO_SCOPES_MSG_LINK_SELECTOR).doesNotExist(); + }); + + test('correct toggles are visible for org level role on manage scopes page', async function (assert) { + await visit(urls.role); + + await click(MANAGE_SCOPES_SELECTOR); + + assert.strictEqual(currentURL(), urls.manageScopes); + 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 save scope keywords to add on manage scopes page', async function (assert) { + instances.role.update({ grant_scope_ids: [] }); + await visit(urls.role); + + await click(`[href="${urls.roleScopes}"]`); + + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0); + + await click(MANAGE_SCOPES_SELECTOR); + await a11yAudit(); + + assert.strictEqual(currentURL(), urls.manageScopes); + + // Click three times to select, unselect, then reselect (for coverage) + await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); + await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); + await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); + await click(SAVE_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.roleScopes); + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 1); + }); + + test('user can cancel scope keywords to add on manage scopes page', async function (assert) { + instances.role.update({ grant_scope_ids: [] }); + await visit(urls.role); + + await click(`[href="${urls.roleScopes}"]`); + + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0); + + await click(MANAGE_SCOPES_SELECTOR); + + assert.strictEqual(currentURL(), urls.manageScopes); + + // Click three times to select, unselect, then reselect (for coverage) + await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); + await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); + await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); + await click(CANCEL_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.roleScopes); + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0); + }); + + test('shows error message on scope save on manage 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.role.update({ grant_scope_ids: [] }); + await visit(urls.role); + + await click(MANAGE_SCOPES_SELECTOR); + await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); + await click(SAVE_BTN_SELECTOR); + + assert.dom(TOAST_SELECTOR).isVisible(); + }); + + test('user can save custom scopes to add on manage org projects page', async function (assert) { + instances.role.update({ grant_scope_ids: [] }); + await visit(urls.role); + + await click(`[href="${urls.roleScopes}"]`); + + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0); + + await click(MANAGE_SCOPES_SELECTOR); + await click(`[href="${urls.manageOrgProjects}"]`); + await a11yAudit(); + + // Click three times to select, unselect, then reselect (for coverage) + await click( + SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id), + ); + await click( + SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id), + ); + await click( + SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id), + ); + await click(SAVE_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.manageScopes); + + await click(SAVE_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.roleScopes); + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 1); + }); + + test('user can cancel custom scopes to add on manage org projects page', async function (assert) { + instances.role.update({ grant_scope_ids: [] }); + await visit(urls.role); + + await click(`[href="${urls.roleScopes}"]`); + + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0); + + await click(MANAGE_SCOPES_SELECTOR); + await click(`[href="${urls.manageOrgProjects}"]`); + await a11yAudit(); + + // Click three times to select, unselect, then reselect (for coverage) + await click( + SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id), + ); + await click( + SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id), + ); + await click( + SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id), + ); + await click(CANCEL_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.manageScopes); + + await click(CANCEL_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.roleScopes); + assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0); + }); + + test('shows error message on custom scope save on manage org projects 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.role.update({ grant_scope_ids: [] }); + await visit(urls.role); + + await click(MANAGE_SCOPES_SELECTOR); + await click(`[href="${urls.manageOrgProjects}"]`); + await click( + SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id), + ); + await click(SAVE_BTN_SELECTOR); + + assert.dom(TOAST_SELECTOR).isVisible(); + }); + + test('user can search for a specifc project scope by id on manage org projects page', async function (assert) { + const anotherProject = this.server.create('scope', { + type: 'project', + scope: { id: instances.scopes.org.id, type: 'org' }, + }); + await visit(urls.role); + + await click(MANAGE_SCOPES_SELECTOR); + await click(`[href="${urls.manageOrgProjects}"]`); + + assert + .dom(SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id)) + .exists(); + assert.dom(SCOPE_CHECKBOX_SELECTOR('project', anotherProject.id)).exists(); + + await fillIn(SEARCH_INPUT_SELECTOR, instances.scopes.project.id); + await waitUntil( + () => + findAll(SCOPE_CHECKBOX_SELECTOR('project', anotherProject.id)) + .length === 0, + ); + + assert + .dom(SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id)) + .exists(); + assert + .dom(SCOPE_CHECKBOX_SELECTOR('project', anotherProject.id)) + .doesNotExist(); + }); + + test('user can search for project scopes and get no results on manage org projects page', async function (assert) { + const anotherProject = this.server.create('scope', { + type: 'project', + scope: { id: instances.scopes.org.id, type: 'org' }, + }); + await visit(urls.role); + + await click(MANAGE_SCOPES_SELECTOR); + await click(`[href="${urls.manageOrgProjects}"]`); + + assert + .dom(SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id)) + .exists(); + assert.dom(SCOPE_CHECKBOX_SELECTOR('project', anotherProject.id)).exists(); + + await fillIn(SEARCH_INPUT_SELECTOR, 'fake scope that does not exist'); + await waitUntil(() => findAll(NO_RESULTS_MSG_SELECTOR).length === 1); + + assert + .dom(SCOPE_CHECKBOX_SELECTOR('project', instances.scopes.project.id)) + .doesNotExist(); + assert + .dom(SCOPE_CHECKBOX_SELECTOR('project', anotherProject.id)) + .doesNotExist(); + assert.dom(NO_RESULTS_MSG_SELECTOR).includesText('No results found'); + }); +}); diff --git a/ui/admin/tests/acceptance/roles/scopes-test.js b/ui/admin/tests/acceptance/roles/scopes-test.js deleted file mode 100644 index 88ad21f06..000000000 --- a/ui/admin/tests/acceptance/roles/scopes-test.js +++ /dev/null @@ -1,351 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import { module, test } from 'qunit'; -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, - GRANT_SCOPE_CHILDREN, - GRANT_SCOPE_DESCENDANTS, -} from 'api/models/role'; - -module('Acceptance | roles | scopes', function (hooks) { - setupApplicationTest(hooks); - setupMirage(hooks); - setupIndexedDb(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, - }, - globalRole: null, - orgRole: null, - }; - const urls = { - orgScope: null, - globalRoles: null, - globalRole: null, - globalRoleScopes: null, - globalManageScopes: null, - orgRoles: null, - orgRole: null, - orgRoleScopes: null, - orgManageScopes: null, - manageCustomScopes: null, - }; - - hooks.beforeEach(function () { - authenticateSession({}); - instances.scopes.global = this.server.create('scope', { id: 'global' }); - instances.scopes.org = this.server.create('scope', { - type: 'org', - scope: { id: 'global', type: 'global' }, - }); - instances.globalRole = this.server.create( - 'role', - { - scope: instances.scopes.global, - }, - 'withScopes', - ); - instances.orgRole = this.server.create( - 'role', - { - scope: instances.scopes.org, - }, - 'withScopes', - ); - - 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.orgRoleScopes); - await a11yAudit(); - assert.strictEqual(currentURL(), urls.orgRoleScopes); - assert - .dom(TABLE_ROW_SELECTOR) - .exists({ count: instances.orgRole.grant_scope_ids.length }); - }); - - test('user can naviage to scope from role grant scopes', async function (assert) { - await visit(urls.orgRoleScopes); - await click('tbody tr:nth-child(2) a'); - assert.strictEqual( - currentURL(), - `/scopes/${instances.scopes.org.id}/scopes`, - ); - }); - - test('user can naviage to parent scope from role grant scopes', async function (assert) { - await visit(urls.orgRoleScopes); - await click('tbody tr:nth-child(2) td:nth-child(3) a'); - assert.strictEqual( - currentURL(), - `/scopes/${instances.scopes.global.id}/scopes`, - ); - }); - - test('user sees no scopes message and action when role has no grant scopes', async function (assert) { - 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.orgRole.update({ - grant_scope_ids: [], - authorized_actions: instances.orgRole.authorized_actions.filter( - (action) => action !== 'set-grant-scopes', - ), - }); - await visit(urls.orgRoleScopes); - assert.dom('.role-grant-scopes div div:nth-child(3) a').doesNotExist(); - }); - - 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.orgRoleScopes}"]`); - - assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0); - - await click(MANAGE_SCOPES_SELECTOR); - - assert.strictEqual(currentURL(), urls.orgManageScopes); - - // Click three times to select, unselect, then reselect (for coverage) - await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); - await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); - await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); - await click(SAVE_BTN_SELECTOR); - - assert.strictEqual(currentURL(), urls.orgRoleScopes); - assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 1); - }); - - test('correct toggles are visible for global level role on manage scopes page', async function (assert) { - await visit(urls.globalRole); - - 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.orgManageScopes); - - // Click three times to select, unselect, then reselect (for coverage) - await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); - await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); - await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); - await click(CANCEL_BTN_SELECTOR); - - assert.strictEqual(currentURL(), urls.orgRoleScopes); - assert.strictEqual(findAll(TABLE_ROW_SELECTOR).length, 0); - }); - - test('shows error message on scope save on manage 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.orgRole.update({ grant_scope_ids: [] }); - await visit(urls.orgRole); - - await click(MANAGE_SCOPES_SELECTOR); - await click(SCOPE_TOGGLE_SELECTOR(GRANT_SCOPE_THIS)); - await click(SAVE_BTN_SELECTOR); - - 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'); - }); -}); diff --git a/ui/admin/tests/unit/controllers/scopes/scope/roles/role/manage-scopes/manage-org-projects-test.js b/ui/admin/tests/unit/controllers/scopes/scope/roles/role/manage-scopes/manage-org-projects-test.js new file mode 100644 index 000000000..3390e78d7 --- /dev/null +++ b/ui/admin/tests/unit/controllers/scopes/scope/roles/role/manage-scopes/manage-org-projects-test.js @@ -0,0 +1,105 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { currentURL, visit, 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-org-projects', + function (hooks) { + setupTest(hooks); + setupMirage(hooks); + setupIndexedDb(hooks); + + let store; + let controller; + + const instances = { + scopes: { + global: null, + org: null, + project: null, + }, + role: null, + }; + + const urls = { + globalScope: null, + manageOrgProjects: 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-org-projects', + ); + + instances.scopes.global = this.server.create('scope', { + id: 'global', + type: 'global', + }); + instances.scopes.org = this.server.create('scope', { + type: 'org', + scope: { id: 'global', type: 'global' }, + }); + instances.scopes.project = this.server.create('scope', { + type: 'project', + scope: { id: instances.scopes.org.id, type: 'org' }, + }); + instances.role = this.server.create('role', { + scope: instances.scopes.global, + grant_scope_ids: [], + }); + + urls.globalScope = `/scopes/global`; + urls.manageOrgProjects = `${urls.globalScope}/roles/${instances.role.id}/manage-scopes/${instances.scopes.org.id}`; + urls.manageCustomScopes = `${urls.globalScope}/roles/${instances.role.id}/manage-scopes/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.manageOrgProjects); + const role = await store.findRecord('role', instances.role.id); + + assert.deepEqual(role.grant_scope_ids, []); + + await controller.setGrantScopes(role, [instances.scopes.project.id]); + + assert.deepEqual(role.grant_scope_ids, [instances.scopes.project.id]); + + await waitUntil(() => currentURL() === urls.manageCustomScopes); + + assert.strictEqual(currentURL(), urls.manageCustomScopes); + }); + + test('cancel action causes transition to expected route', async function (assert) { + await visit(urls.manageOrgProjects); + const role = await store.findRecord('role', instances.role.id); + + await controller.cancel(role); + await waitUntil(() => currentURL() === urls.manageCustomScopes); + + assert.strictEqual(currentURL(), urls.manageCustomScopes); + }); + }, +); diff --git a/ui/admin/tests/unit/routes/scopes/scope/roles/role/manage-scopes/manage-org-projects-test.js b/ui/admin/tests/unit/routes/scopes/scope/roles/role/manage-scopes/manage-org-projects-test.js new file mode 100644 index 000000000..a0362528b --- /dev/null +++ b/ui/admin/tests/unit/routes/scopes/scope/roles/role/manage-scopes/manage-org-projects-test.js @@ -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-org-projects', + function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup( + 'route:scopes/scope/roles/role/manage-scopes/manage-org-projects', + ); + assert.ok(route); + }); + }, +);