diff --git a/changelog/_11798.txt b/changelog/_11798.txt new file mode 100644 index 0000000000..93a22bbe39 --- /dev/null +++ b/changelog/_11798.txt @@ -0,0 +1,3 @@ +```release-note:feature +**UI Policy Generator (Enterprise)**: Adds policy generator flyout to KV V2 secrets engine prepopulated with relevant API requests for each page. +``` \ No newline at end of file diff --git a/ui/app/services/capabilities.ts b/ui/app/services/capabilities.ts index 32e9c1db3e..c6cb36e274 100644 --- a/ui/app/services/capabilities.ts +++ b/ui/app/services/capabilities.ts @@ -6,6 +6,7 @@ import Service, { service } from '@ember/service'; import { sanitizePath, sanitizeStart } from 'core/utils/sanitize-path'; import { PATH_MAP, SUDO_PATHS, SUDO_PATH_PREFIXES } from 'vault/utils/constants/capabilities'; +import { tracked } from '@glimmer/tracking'; import type ApiService from 'vault/services/api'; import type NamespaceService from 'vault/services/namespace'; @@ -15,6 +16,8 @@ export default class CapabilitiesService extends Service { @service declare readonly api: ApiService; @service declare readonly namespace: NamespaceService; + @tracked requestedPaths = new Set([]); + /* Add API paths to the PATH_MAP constant using a friendly key, e.g. 'syncDestinations'. Use the apiPath tagged template literal to build the path with dynamic segments @@ -83,6 +86,8 @@ export default class CapabilitiesService extends Service { } async fetch(paths: string[]): Promise { + this.requestedPaths = new Set(paths); + const payload = { paths: paths.map((path) => this.relativeNamespacePath(path)) }; try { diff --git a/ui/lib/core/addon/components/code-generator/policy/flyout.hbs b/ui/lib/core/addon/components/code-generator/policy/flyout.hbs index 4163794234..bab5d655cb 100644 --- a/ui/lib/core/addon/components/code-generator/policy/flyout.hbs +++ b/ui/lib/core/addon/components/code-generator/policy/flyout.hbs @@ -3,102 +3,109 @@ SPDX-License-Identifier: BUSL-1.1 }} - +{{#if this.version.isEnterprise}} + {{#if (has-block "customTrigger")}} + {{! Passes the open flyout action to any yielded component }} + {{yield this.openFlyout to="customTrigger"}} + {{else}} + + {{/if}} -{{#if this.showFlyout}} - - - Policy generator - + {{#if this.showFlyout}} + + + Policy generator + + + + + {{#if this.errorMessage}} + + + + {{/if}} - - - {{#if this.errorMessage}} - + + Policy name + {{#if (this.validationError "name")}} + {{this.validationError "name"}} + {{/if}} + - {{/if}} - - - Policy name - {{#if (this.validationError "name")}} - {{this.validationError "name"}} - {{/if}} - - + + + Policy rules + + Use + * + for wildcard matching. + + - - - Policy rules - - Use - * - for wildcard matching. - - + + - + + + + + <:toggle>Automation snippets + <:content> + + + + + + + + + + + - - - - - - - - <:toggle>Automation snippets - <:content> - - - - - - - - - - - - - - - + + + + + {{/if}} {{/if}} \ No newline at end of file diff --git a/ui/lib/core/addon/components/code-generator/policy/flyout.ts b/ui/lib/core/addon/components/code-generator/policy/flyout.ts index ab7df006fe..eead601cb5 100644 --- a/ui/lib/core/addon/components/code-generator/policy/flyout.ts +++ b/ui/lib/core/addon/components/code-generator/policy/flyout.ts @@ -6,7 +6,7 @@ import { action } from '@ember/object'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; -import { policySnippetArgs, PolicyStanza } from 'core/utils/code-generators/policy'; +import { formatStanzas, policySnippetArgs, PolicyStanza } from 'core/utils/code-generators/policy'; import { validate } from 'vault/utils/forms/validate'; import { service } from '@ember/service'; import { task } from 'ember-concurrency'; @@ -15,23 +15,33 @@ import type { HTMLElementEvent } from 'vault/forms'; import type { PolicyData } from './builder'; import type { ValidationMap, Validations } from 'vault/vault/app-types'; import type ApiService from 'vault/services/api'; +import type CapabilitiesService from 'vault/services/capabilities'; import type FlashMessageService from 'ember-cli-flash/services/flash-messages'; +import type VersionService from 'vault/services/version'; -export default class CodeGeneratorPolicyFlyout extends Component { +interface Args { + onClose?: CallableFunction; +} + +export default class CodeGeneratorPolicyFlyout extends Component { @service declare readonly api: ApiService; + @service declare readonly capabilities: CapabilitiesService; @service declare readonly flashMessages: FlashMessageService; + @service declare readonly version: VersionService; + + defaultStanzas = [new PolicyStanza()]; + + validations: Validations = { + name: [{ type: 'presence', message: 'Name is required.' }], + }; @tracked errorMessage = ''; @tracked policyContent = ''; @tracked policyName = ''; @tracked showFlyout = false; - @tracked stanzas: PolicyStanza[] = [new PolicyStanza()]; + @tracked stanzas: PolicyStanza[] = this.defaultStanzas; @tracked validationErrors: ValidationMap | null = null; - validations: Validations = { - name: [{ type: 'presence', message: 'Name is required.' }], - }; - validationError = (param: string) => { const { isValid, errors } = this.validationErrors?.[param] ?? {}; return !isValid && errors ? errors.join(' ') : ''; @@ -84,9 +94,26 @@ export default class CodeGeneratorPolicyFlyout extends Component { } @action - onClose() { + openFlyout() { + this.showFlyout = true; + const presetStanzas = Array.from(this.capabilities.requestedPaths).map( + (path) => new PolicyStanza({ path }) + ); + + const defaultState = formatStanzas(this.defaultStanzas); + const currentState = formatStanzas(this.stanzas); + const noChanges = currentState === defaultState; + // Only preset stanzas if no changes have been made to the flyout + if (presetStanzas.length && noChanges) { + this.stanzas = presetStanzas; + } + } + + @action + closeFlyout() { this.showFlyout = false; this.resetErrors(); + this.args.onClose?.(); } resetErrors() { diff --git a/ui/lib/core/addon/utils/code-generators/policy.ts b/ui/lib/core/addon/utils/code-generators/policy.ts index 8907567744..18a0b793cd 100644 --- a/ui/lib/core/addon/utils/code-generators/policy.ts +++ b/ui/lib/core/addon/utils/code-generators/policy.ts @@ -17,7 +17,11 @@ export type AclCapability = (typeof ACL_CAPABILITIES)[number]; // 'create' | 're export class PolicyStanza { @tracked capabilities: Set = new Set(); - @tracked path = ''; + @tracked path; + + constructor({ path = '' } = {}) { + this.path = path; + } get preview() { return aclTemplate(this.path, Array.from(this.capabilities)); diff --git a/ui/lib/kv/addon/components/page/list.hbs b/ui/lib/kv/addon/components/page/list.hbs index d2477616f0..a3ba752d8e 100644 --- a/ui/lib/kv/addon/components/page/list.hbs +++ b/ui/lib/kv/addon/components/page/list.hbs @@ -13,6 +13,13 @@ <:actions> + + <:customTrigger as |openFlyout|> + + Generate policy + + + + <:badges> {{#if @path}} {{/if}} + + <:actions> + + diff --git a/ui/lib/kv/addon/components/page/secret/metadata/details.hbs b/ui/lib/kv/addon/components/page/secret/metadata/details.hbs index ab2de56566..4bb66c1bf5 100644 --- a/ui/lib/kv/addon/components/page/secret/metadata/details.hbs +++ b/ui/lib/kv/addon/components/page/secret/metadata/details.hbs @@ -7,11 +7,16 @@ <:breadcrumbs> + <:badges> {{#if @path}} {{/if}} + + <:actions> + + diff --git a/ui/lib/kv/addon/components/page/secret/metadata/version-diff.hbs b/ui/lib/kv/addon/components/page/secret/metadata/version-diff.hbs index a9339c3827..ed668c5586 100644 --- a/ui/lib/kv/addon/components/page/secret/metadata/version-diff.hbs +++ b/ui/lib/kv/addon/components/page/secret/metadata/version-diff.hbs @@ -7,6 +7,10 @@ <:breadcrumbs> + + <:actions> + + diff --git a/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs b/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs index 3be8af7df4..9d5ed773ea 100644 --- a/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs +++ b/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs @@ -7,11 +7,16 @@ <:breadcrumbs> + <:badges> {{#if @path}} {{/if}} + + <:actions> + + diff --git a/ui/lib/kv/addon/components/page/secret/overview.hbs b/ui/lib/kv/addon/components/page/secret/overview.hbs index f4f20e994d..4c8d4d9523 100644 --- a/ui/lib/kv/addon/components/page/secret/overview.hbs +++ b/ui/lib/kv/addon/components/page/secret/overview.hbs @@ -7,11 +7,16 @@ <:breadcrumbs> + <:badges> {{#if @path}} {{/if}} + + <:actions> + + diff --git a/ui/lib/kv/addon/components/page/secret/paths.hbs b/ui/lib/kv/addon/components/page/secret/paths.hbs index 2bf339346d..100cb920f0 100644 --- a/ui/lib/kv/addon/components/page/secret/paths.hbs +++ b/ui/lib/kv/addon/components/page/secret/paths.hbs @@ -7,11 +7,16 @@ <:breadcrumbs> + <:badges> {{#if @path}} {{/if}} + + <:actions> + + diff --git a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-navigation-test.js b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-navigation-test.js index c255bc5a9a..790e140f7b 100644 --- a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-navigation-test.js +++ b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-navigation-test.js @@ -15,6 +15,7 @@ import { typeIn, visit, waitUntil, + waitFor, } from '@ember/test-helpers'; import { setupApplicationTest } from 'vault/tests/helpers'; import { login } from 'vault/tests/helpers/auth/auth-helpers'; @@ -45,6 +46,14 @@ const navToBackend = async (backend) => { await visit(`/vault/secrets-engines`); return click(`${GENERAL.tableData(`${backend}/`, 'path')} a`); }; +const assertPolicyGenerator = async (assert, expectedPaths) => { + assert.dom(GENERAL.cardContainer()).exists({ count: expectedPaths.length }); + expectedPaths.forEach((path, idx) => { + assert + .dom(`${GENERAL.cardContainer(idx)} ${GENERAL.inputByAttr('path')}`) + .hasValue(path, `flyout is prepopulated with path: "${path}"`); + }); +}; const assertCorrectBreadcrumbs = (assert, expected) => { assert.dom(PAGE.breadcrumbs).hasText(expected.join(' ')); const breadcrumbs = findAll(PAGE.breadcrumb); @@ -235,6 +244,49 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) { ); }); + test('it does not render policy generator on community', async function (assert) { + this.version.type = 'community'; + await visit(`/vault/secrets-engines/${this.backend}/kv/list`); + await waitUntil(() => currentRouteName() === 'vault.cluster.secrets.backend.kv.list'); + await click(GENERAL.dropdownToggle('Manage')); + await waitFor(GENERAL.menuItem('Configure')); + assert.dom(GENERAL.menuItem('Generate policy')).doesNotExist(); + await visit(`/vault/secrets-engines/${this.backend}/kv/${encodeURIComponent('app/nested/secret')}`); + await waitUntil(() => currentRouteName() === 'vault.cluster.secrets.backend.kv.secret.index'); + for (const tab of ALL_TABS) { + await click(PAGE.secretTab(tab)); + assert.dom(GENERAL.button('Generate policy')).doesNotExist(); + } + }); + + test('enterprise: it renders policy generator on each page header', async function (assert) { + await visit(`/vault/secrets-engines/${this.backend}/kv/list`); + await waitUntil(() => currentRouteName() === 'vault.cluster.secrets.backend.kv.list'); + await click(GENERAL.dropdownToggle('Manage')); + await waitFor(GENERAL.menuItem('Generate policy')); + assert.dom(GENERAL.menuItem('Generate policy')).exists(); + await click(GENERAL.menuItem('Generate policy')); + assertPolicyGenerator(assert, [`${this.backend}/metadata/`]); + + const secretName = 'app/nested/secret'; + const expectedPaths = [ + `${this.backend}/metadata/${secretName}`, + `${this.backend}/data/${secretName}`, + `${this.backend}/subkeys/${secretName}`, + `${this.backend}/delete/${secretName}`, + `${this.backend}/undelete/${secretName}`, + `${this.backend}/destroy/${secretName}`, + ]; + await visit(`/vault/secrets-engines/${this.backend}/kv/${encodeURIComponent(secretName)}`); + await waitUntil(() => currentRouteName() === 'vault.cluster.secrets.backend.kv.secret.index'); + for (const tab of ALL_TABS) { + await click(PAGE.secretTab(tab)); + assert.dom(GENERAL.button('Generate policy')).exists(); + await click(GENERAL.button('Generate policy')); + assertPolicyGenerator(assert, expectedPaths); + } + }); + module('admin persona', function (hooks) { hooks.beforeEach(async function () { const token = await runCmd( diff --git a/ui/tests/integration/components/code-generator/policy/flyout-test.js b/ui/tests/integration/components/code-generator/policy/flyout-test.js index b61145c108..9e91b0afb7 100644 --- a/ui/tests/integration/components/code-generator/policy/flyout-test.js +++ b/ui/tests/integration/components/code-generator/policy/flyout-test.js @@ -5,18 +5,26 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render, click, fillIn } from '@ember/test-helpers'; +import { render, click, fillIn, typeIn } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { overrideResponse } from 'vault/tests/helpers/stubs'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import Sinon from 'sinon'; +const SELECTORS = { + pathByContainer: (idx) => `${GENERAL.cardContainer(idx)} ${GENERAL.inputByAttr('path')}`, + checkboxByContainer: (idx, cap) => `${GENERAL.cardContainer(idx)} ${GENERAL.checkboxByAttr(cap)}`, +}; + module('Integration | Component | code-generator/policy/flyout', function (hooks) { setupRenderingTest(hooks); setupMirage(hooks); hooks.beforeEach(function () { + this.version = this.owner.lookup('service:version'); + this.version.type = 'enterprise'; // the flyout is only available for enterprise versions + this.onClose = undefined; this.assertSaveRequest = (assert, expectedPolicy, msg = 'policy content is correct') => { this.server.post('/sys/policies/acl/:name', (_, req) => { const { policy } = JSON.parse(req.requestBody); @@ -27,13 +35,26 @@ module('Integration | Component | code-generator/policy/flyout', function (hooks }); }; this.renderComponent = async ({ open = true } = {}) => { - await render(hbs``); + await render(hbs``); if (open) { await click(GENERAL.button('Generate policy')); } }; }); + test('it calls onClose callback', async function (assert) { + this.onClose = Sinon.spy(); + await this.renderComponent(); + await click(GENERAL.cancelButton); + assert.true(this.onClose.calledOnce, 'onClose callback is called'); + }); + + test('it does not render for community versions', async function (assert) { + this.version.type = 'community'; + await this.renderComponent({ open: false }); + assert.dom(GENERAL.button('Generate policy')).doesNotExist('Button does not render for CE version'); + }); + test('it renders button trigger and opens and closes the flyout', async function (assert) { await this.renderComponent({ open: false }); assert.dom(GENERAL.button('Generate policy')).exists().hasText('Generate policy'); @@ -51,6 +72,71 @@ module('Integration | Component | code-generator/policy/flyout', function (hooks assert.dom(GENERAL.flyout).doesNotExist('flyout closes after clicking cancel'); }); + test('it yields custom trigger component', async function (assert) { + await render(hbs` + + + <:customTrigger as |openFlyout|> + + Make me a policy! + + + + Magic stuff + `); + await click(GENERAL.dropdownToggle('Toolbox')); + assert.dom(GENERAL.flyout).doesNotExist(); + assert + .dom(GENERAL.button('Make me a policy!')) + .exists() + .hasText('Make me a policy!', 'custom trigger renders'); + await click(GENERAL.button('Make me a policy!')); + assert.dom(GENERAL.flyout).exists('flyout opens after clicking custom trigger'); + }); + + // This test is to demonstrate how to implement closing the dropdown when the flyout trigger is a dropdown element + test('it closes dropdown if custom trigger is a dropdown item', async function (assert) { + await render(hbs` + + + <:customTrigger as |openFlyout|> + + Make me a policy! + + + + Magic stuff + `); + await click(GENERAL.dropdownToggle('Toolbox')); + assert.dom(GENERAL.dropdownToggle('Toolbox')).hasAttribute('aria-expanded', 'true'); + await click(GENERAL.button('Make me a policy!')); + assert.dom(GENERAL.flyout).exists('flyout is open'); + await click(GENERAL.cancelButton); + assert.dom(GENERAL.flyout).doesNotExist('flyout is closed'); + assert + .dom(GENERAL.dropdownToggle('Toolbox')) + .hasAttribute('aria-expanded', 'false', 'dropdown closes when flyout is closed'); + }); + + test('it does not render yielded custom trigger component on community', async function (assert) { + this.version.type = 'community'; + await this.renderComponent({ open: false }); + await render(hbs` + + + <:customTrigger as |openFlyout|> + + Make me a policy! + + + + Magic stuff + `); + await click(GENERAL.dropdownToggle('Toolbox')); + assert.dom(GENERAL.button('Magic stuff')).exists('dropdown opens'); + assert.dom(GENERAL.button('Make me a policy!')).doesNotExist(); + }); + test('it preserves state when re-opened', async function (assert) { assert.expect(3); await this.renderComponent(); @@ -193,8 +279,8 @@ EOT`; await click(GENERAL.checkboxByAttr('read')); await click(GENERAL.button('Add rule')); - await fillIn(`${GENERAL.cardContainer('1')} ${GENERAL.inputByAttr('path')}`, 'second/path'); - await click(`${GENERAL.cardContainer('1')} ${GENERAL.checkboxByAttr('update')}`); + await fillIn(SELECTORS.pathByContainer(1), 'second/path'); + await click(SELECTORS.checkboxByContainer(1, 'update')); await click(GENERAL.submitButton); }); @@ -257,4 +343,84 @@ EOT`; assert.dom(GENERAL.messageError).doesNotExist('error banner is cleared'); assert.dom(GENERAL.validationErrorByAttr('name')).doesNotExist('validation error is cleared'); }); + + module('capabilities', function (hooks) { + hooks.beforeEach(function () { + this.capabilities = this.owner.lookup('service:capabilities'); + }); + + test('it renders when no capabilities have been requested', async function (assert) { + this.capabilities.requestedPaths = new Set([]); + await this.renderComponent(); + assert.dom(SELECTORS.pathByContainer(0)).hasValue(''); + }); + + test('it prepopulates with a single capability path', async function (assert) { + this.capabilities.requestedPaths = new Set(['super-secret/data']); + await this.renderComponent(); + assert.dom(SELECTORS.pathByContainer(0)).hasValue('super-secret/data'); + assert.dom(GENERAL.cardContainer()).exists({ count: 1 }); + }); + + test('it prepopulates with a multiple capability paths', async function (assert) { + this.capabilities.requestedPaths = new Set(['path/one', 'path/two']); + await this.renderComponent(); + assert.dom(SELECTORS.pathByContainer(0)).hasValue('path/one'); + assert.dom(SELECTORS.pathByContainer(1)).hasValue('path/two'); + assert.dom(GENERAL.cardContainer()).exists({ count: 2 }); + }); + + test('it does not override user changes to a preset path on reopen', async function (assert) { + this.capabilities.requestedPaths = new Set(['super-secret/data']); + await this.renderComponent(); + + // User updates path + await typeIn(SELECTORS.pathByContainer(0), '/*'); + // Close and reopen + await click(GENERAL.cancelButton); + await click(GENERAL.button('Generate policy')); + assert + .dom(SELECTORS.pathByContainer(0)) + .hasValue('super-secret/data/*', 'user path changes are preserved'); + assert.dom(GENERAL.cardContainer()).exists({ count: 1 }); + }); + + test('it does not override user capabilities selection for a preset path on reopen', async function (assert) { + this.capabilities.requestedPaths = new Set(['super-secret/data']); + await this.renderComponent(); + + // User updates path + await click(SELECTORS.checkboxByContainer(0, 'read')); + // Close and reopen + await click(GENERAL.cancelButton); + await click(GENERAL.button('Generate policy')); + assert + .dom(SELECTORS.checkboxByContainer(0, 'read')) + .isChecked('user capabilities changes are preserved'); + assert.dom(GENERAL.cardContainer()).exists({ count: 1 }); + }); + + test('it does not override user added stanza on reopen', async function (assert) { + this.capabilities.requestedPaths = new Set(['super-secret/data']); + await this.renderComponent(); + await click(GENERAL.button('Add rule')); + await fillIn(SELECTORS.pathByContainer(1), 'new/path/*'); + // Close and reopen + await click(GENERAL.cancelButton); + await click(GENERAL.button('Generate policy')); + assert.dom(GENERAL.cardContainer()).exists({ count: 2 }, 'it renders two stanzas after reopening'); + assert.dom(SELECTORS.pathByContainer(0)).hasValue('super-secret/data', 'preset path still exists'); + assert.dom(SELECTORS.pathByContainer(1)).hasValue('new/path/*', 'user added path still exists'); + }); + + test('it does not save prepopulated paths as policy content', async function (assert) { + assert.expect(3); + this.capabilities.requestedPaths = new Set(['path/one', 'path/two']); + await this.renderComponent(); + // Fill in name and save to make sure policyContent is empty + this.assertSaveRequest(assert, '', 'policy content is empty despite pre-filled paths'); + await fillIn(GENERAL.inputByAttr('name'), 'test-policy'); + await click(GENERAL.submitButton); + }); + }); }); diff --git a/ui/tests/integration/utils/code-generators/policy-test.js b/ui/tests/integration/utils/code-generators/policy-test.js index 2a35387227..3b69826202 100644 --- a/ui/tests/integration/utils/code-generators/policy-test.js +++ b/ui/tests/integration/utils/code-generators/policy-test.js @@ -89,6 +89,11 @@ module('Integration | Util | code-generators/policy', function (hooks) { assert.strictEqual(stanza.capabilities.size, 0, 'capabilities set is empty'); }); + test('PolicyStanza: it sets path when instantiated with a path value', async function (assert) { + const stanza = new PolicyStanza({ path: 'my-path' }); + assert.strictEqual(stanza.path, 'my-path', 'path is sets'); + }); + test('PolicyStanza: it generates preview for single capability', async function (assert) { const stanza = new PolicyStanza(); stanza.path = 'secret/data/*'; diff --git a/ui/tests/unit/services/capabilities-test.js b/ui/tests/unit/services/capabilities-test.js index 48fb184b93..8f29fdc408 100644 --- a/ui/tests/unit/services/capabilities-test.js +++ b/ui/tests/unit/services/capabilities-test.js @@ -142,6 +142,45 @@ module('Unit | Service | capabilities', function (hooks) { assert.propEqual(actual, expected, `it returns expected response: ${JSON.stringify(actual)}`); }); + test('fetch: it tracks the requested paths', async function (assert) { + const paths = ['/my/api/path', 'another/api/path']; + + this.server.post('/sys/capabilities-self', () => { + return this.generateResponse({ + paths, + capabilities: { '/my/api/path': ['read'], 'another/api/path': ['update'] }, + }); + }); + + assert.strictEqual(this.capabilities.requestedPaths.size, 0, 'requestedPaths is empty before fetch'); + + await this.capabilities.fetch(paths); + + assert.strictEqual(this.capabilities.requestedPaths.size, 2, 'requestedPaths contains 2 items'); + assert.true(this.capabilities.requestedPaths.has('/my/api/path'), 'contains first path'); + assert.true(this.capabilities.requestedPaths.has('another/api/path'), 'contains second path'); + }); + + test('fetch: it replaces requestedPaths on each call', async function (assert) { + const firstPaths = ['/path/one', '/path/two']; + const secondPaths = ['/path/three']; + + this.server.post('/sys/capabilities-self', () => { + return this.generateResponse({ + paths: firstPaths, + capabilities: { '/path/one': ['read'], '/path/two': ['read'], '/path/three': ['read'] }, + }); + }); + + await this.capabilities.fetch(firstPaths); + assert.strictEqual(this.capabilities.requestedPaths.size, 2, 'initially has 2 paths'); + + await this.capabilities.fetch(secondPaths); + assert.strictEqual(this.capabilities.requestedPaths.size, 1, 'updated to have 1 path'); + assert.true(this.capabilities.requestedPaths.has('/path/three'), 'contains new path'); + assert.false(this.capabilities.requestedPaths.has('/path/one'), 'no longer contains old path'); + }); + test('fetchPathCapabilities: it makes request to capabilities-self and returns capabilities for single path', async function (assert) { const path = '/my/api/path'; const expectedPayload = { paths: [path] };