diff --git a/ui/app/services/capabilities.ts b/ui/app/services/capabilities.ts index c6cb36e274..c7ec786b87 100644 --- a/ui/app/services/capabilities.ts +++ b/ui/app/services/capabilities.ts @@ -12,11 +12,44 @@ import type ApiService from 'vault/services/api'; import type NamespaceService from 'vault/services/namespace'; import type { Capabilities, CapabilitiesMap, CapabilitiesData, CapabilityTypes } from 'vault/app-types'; +type CapabilityFetchOptions = { + routeForCache?: string; +}; + export default class CapabilitiesService extends Service { @service declare readonly api: ApiService; @service declare readonly namespace: NamespaceService; - @tracked requestedPaths = new Set([]); + /* + * API path caching for + * Cache API paths when capabilities are fetched during route model hooks + * so the flyout can prefill with relevant policy paths. + */ + @tracked routePathCache = new Map>(); + + // Cache API paths requested for a particular route + cacheRoutePaths(route: string, apiPaths: string[]) { + this.routePathCache.set(route, new Set(apiPaths)); + } + + // Lookup the paths for a route. Returns exact match first then falls back to longest matching route prefix. + lookupRoutePaths(route: string) { + const exactMatch = this.routePathCache.get(route); + if (exactMatch) { + return exactMatch; + } + + const routes = Array.from(this.routePathCache.keys()); + const matchingRoutes = routes.filter((r) => route.includes(r)); + if (matchingRoutes.length) { + // Return longest matching cached route which is the most specific parent + const bestMatch = matchingRoutes.reduce((a, b) => (a.length > b.length ? a : b), ''); + return this.routePathCache.get(bestMatch); + } + + return null; + } + /* End logic for API path caching */ /* Add API paths to the PATH_MAP constant using a friendly key, e.g. 'syncDestinations'. @@ -33,6 +66,10 @@ export default class CapabilitiesService extends Service { return path(params || {}); } + pathsForList(paths: (keyof typeof PATH_MAP)[], params: object) { + return paths.map((path) => this.pathFor(path, params)); + } + /* Users don't always have access to the capabilities-self endpoint in the current namespace. This can happen when logging in to a namespace and then navigating to a child namespace. @@ -85,8 +122,11 @@ export default class CapabilitiesService extends Service { }, {}); } - async fetch(paths: string[]): Promise { - this.requestedPaths = new Set(paths); + async fetch(paths: string[], fetchOptions: CapabilityFetchOptions = {}): Promise { + // Cache API paths if route name provided + if (fetchOptions?.routeForCache) { + this.cacheRoutePaths(fetchOptions.routeForCache, paths); + } const payload = { paths: paths.map((path) => this.relativeNamespacePath(path)) }; @@ -116,16 +156,16 @@ export default class CapabilitiesService extends Service { // convenience method for fetching capabilities for a singular path without needing to use pathFor // ex: capabilities.for('syncDestinations', { type: 'github', name: 'org-sync' }); - async for(key: keyof typeof PATH_MAP, params?: T) { + async for(key: keyof typeof PATH_MAP, params?: T, fetchOptions: CapabilityFetchOptions = {}) { const path = this.pathFor(key, params); - return this.fetchPathCapabilities(path); + return this.fetchPathCapabilities(path, fetchOptions); } /* this method returns all of the capabilities for a singular path */ - async fetchPathCapabilities(path: string) { - const capabilities = await this.fetch([path]); + async fetchPathCapabilities(path: string, fetchOptions: CapabilityFetchOptions = {}) { + const capabilities = await this.fetch([path], fetchOptions); return capabilities[path] as Capabilities; } 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 ebc59c94ad..796729aa66 100644 --- a/ui/lib/core/addon/components/code-generator/policy/flyout.ts +++ b/ui/lib/core/addon/components/code-generator/policy/flyout.ts @@ -10,6 +10,7 @@ import { formatStanzas, policySnippetArgs, PolicyStanza } from 'core/utils/code- import { validate } from 'vault/utils/forms/validate'; import { service } from '@ember/service'; import { task } from 'ember-concurrency'; +import routerLookup from 'core/utils/router-lookup'; import type { HTMLElementEvent } from 'vault/forms'; import type { PolicyData } from './builder'; @@ -18,9 +19,11 @@ 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'; +import type RouterService from '@ember/routing/router-service'; interface Args { onClose?: CallableFunction; + policyPaths: string[]; } export default class CodeGeneratorPolicyFlyout extends Component { @@ -42,6 +45,10 @@ export default class CodeGeneratorPolicyFlyout extends Component { @tracked stanzas: PolicyStanza[] = this.defaultStanzas; @tracked validationErrors: ValidationMap | null = null; + get router(): RouterService { + return routerLookup(this); + } + validationError = (param: string) => { const { isValid, errors } = this.validationErrors?.[param] ?? {}; return !isValid && errors ? errors.join(' ') : ''; @@ -97,16 +104,14 @@ export default class CodeGeneratorPolicyFlyout extends Component { @action 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; + if (noChanges) { + const presetStanzas = this.computePolicyPaths(); + this.stanzas = presetStanzas ? presetStanzas : this.stanzas; } } @@ -127,4 +132,24 @@ export default class CodeGeneratorPolicyFlyout extends Component { this.policyContent = ''; this.stanzas = [new PolicyStanza()]; } + + computePolicyPaths() { + // Explicit policy paths take precedence + if (this.args.policyPaths) { + return this.presetStanzas(this.args.policyPaths); + } + + const currentRouteName = this.router.currentRouteName; + if (!currentRouteName) { + return null; + } + + const routePolicyPaths = this.capabilities.lookupRoutePaths(currentRouteName); + return routePolicyPaths ? this.presetStanzas(Array.from(routePolicyPaths)) : null; + } + + presetStanzas(paths: string[]) { + const presetStanzas = paths.map((path) => new PolicyStanza({ path })); + return presetStanzas.length ? presetStanzas : null; + } } diff --git a/ui/lib/core/addon/components/linked-block.js b/ui/lib/core/addon/components/linked-block.js index 04e30105d2..4d6b0e4542 100644 --- a/ui/lib/core/addon/components/linked-block.js +++ b/ui/lib/core/addon/components/linked-block.js @@ -4,9 +4,9 @@ */ import Component from '@glimmer/component'; -import { getOwner } from '@ember/owner'; import { action } from '@ember/object'; import { encodePath } from 'vault/utils/path-encoding-helpers'; +import routerLookup from 'core/utils/router-lookup'; /** * @module LinkedBlock @@ -26,13 +26,8 @@ import { encodePath } from 'vault/utils/path-encoding-helpers'; */ export default class LinkedBlockComponent extends Component { - // We don't import the router service here because Ember Engine's use the alias 'app-router' - // Since this component is shared across engines, we look up the router dynamically using getOwner instead. - // This way we avoid throwing an error by looking up a service that doesn't exist. - // https://guides.emberjs.com/release/services/#toc_accessing-services get router() { - const owner = getOwner(this); - return owner.lookup('service:router') || owner.lookup('service:app-router'); + return routerLookup(this); } @action diff --git a/ui/lib/core/addon/utils/router-lookup.ts b/ui/lib/core/addon/utils/router-lookup.ts new file mode 100644 index 0000000000..cf29292f36 --- /dev/null +++ b/ui/lib/core/addon/utils/router-lookup.ts @@ -0,0 +1,20 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { getOwner } from '@ember/owner'; + +import type RouterService from '@ember/routing/router-service'; + +// In components shared across engines, we have to look up the router dynamically +// and use getOwner because Ember Engine's use the alias 'app-router'. +// This way we avoid throwing an error by looking up a service that doesn't exist. +// https://guides.emberjs.com/release/services/#toc_accessing-services +export default function routerLookup(context: object) { + const owner = getOwner(context); + return ( + (owner?.lookup('service:router') as RouterService) || + (owner?.lookup('service:app-router') as RouterService) + ); +} diff --git a/ui/lib/kv/addon/routes/list-directory.js b/ui/lib/kv/addon/routes/list-directory.js index dafa206ff0..f199d01e7b 100644 --- a/ui/lib/kv/addon/routes/list-directory.js +++ b/ui/lib/kv/addon/routes/list-directory.js @@ -57,7 +57,11 @@ export default class KvSecretsListRoute extends Route { const backend = this.secretMountPath.currentPath; const filterValue = pathToSecret ? (pageFilter ? pathToSecret + pageFilter : pathToSecret) : pageFilter; const secrets = await this.fetchMetadata(backend, pathToSecret, params); - const capabilities = await this.capabilities.for('kvMetadata', { backend, path: path_to_secret }); + const capabilities = await this.capabilities.for( + 'kvMetadata', + { backend, path: path_to_secret }, + { routeForCache: 'vault.cluster.secrets.backend.kv' } + ); const backendModel = this.modelFor('application'); return { diff --git a/ui/lib/kv/addon/routes/secret.js b/ui/lib/kv/addon/routes/secret.js index a03ce71d44..7deea498d8 100644 --- a/ui/lib/kv/addon/routes/secret.js +++ b/ui/lib/kv/addon/routes/secret.js @@ -65,14 +65,10 @@ export default class KvSecretRoute extends Route { const undeletePath = `${backend}/undelete/${path}`; const destroyPath = `${backend}/destroy/${path}`; - const perms = await this.capabilities.fetch([ - metadataPath, - dataPath, - subkeysPath, - deletePath, - undeletePath, - destroyPath, - ]); + const apiPaths = [metadataPath, dataPath, subkeysPath, deletePath, undeletePath, destroyPath]; + const perms = await this.capabilities.fetch(apiPaths, { + routeForCache: 'vault.cluster.secrets.backend.kv.secret', + }); return { canReadData: perms[dataPath].canRead, 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 fdc1e87dc8..59e517264e 100644 --- a/ui/tests/integration/components/code-generator/policy/flyout-test.js +++ b/ui/tests/integration/components/code-generator/policy/flyout-test.js @@ -25,6 +25,7 @@ module('Integration | Component | code-generator/policy/flyout', function (hooks this.version = this.owner.lookup('service:version'); this.version.type = 'enterprise'; // the flyout is only available for enterprise versions this.onClose = undefined; + this.policyPaths = undefined; this.assertSaveRequest = (assert, expectedPolicy, msg = 'policy content is correct') => { this.server.post('/sys/policies/acl/:name', (_, req) => { const { policy } = JSON.parse(req.requestBody); @@ -35,7 +36,9 @@ 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')); } @@ -72,6 +75,20 @@ module('Integration | Component | code-generator/policy/flyout', function (hooks assert.dom(GENERAL.flyout).doesNotExist('flyout closes after clicking cancel'); }); + test('it presets with paths from @policyPaths array', async function (assert) { + this.policyPaths = ['some/preset/path']; + await this.renderComponent(); + assert.dom(SELECTORS.pathByContainer(0)).hasValue('some/preset/path'); + assert.dom(GENERAL.cardContainer()).exists({ count: 1 }); + }); + + test('it handles empty @policyPaths array', async function (assert) { + this.policyPaths = []; + await this.renderComponent(); + + assert.dom(SELECTORS.pathByContainer(0)).hasValue('', 'does not prepopulate with empty array'); + }); + test('it yields custom trigger component', async function (assert) { await render(hbs` @@ -346,83 +363,138 @@ EOT`; assert.dom(GENERAL.validationErrorByAttr('name')).doesNotExist('validation error is cleared'); }); - module('capabilities', function (hooks) { + module('capabilities service prepopulating', function (hooks) { hooks.beforeEach(function () { this.capabilities = this.owner.lookup('service:capabilities'); + const router = this.owner.lookup('service:router'); + this.currentRouteNameStub = Sinon.stub(router, 'currentRouteName'); + this.cacheCapabilityPaths = (route, paths) => { + this.capabilities.cacheRoutePaths(route, paths); + }; }); - test('it renders when no capabilities have been requested', async function (assert) { - this.capabilities.requestedPaths = new Set([]); + hooks.afterEach(function () { + this.currentRouteNameStub.restore(); + }); + + test('it handles null currentRouteName gracefully', async function (assert) { + this.currentRouteNameStub.value(null); + await this.renderComponent(); + assert.dom(SELECTORS.pathByContainer(0)).hasValue('', 'does not prepopulate when route name is null'); + assert.dom(GENERAL.cardContainer()).exists({ count: 1 }); + }); + + test('it does not prepopulate when no paths have been cached', async function (assert) { 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']); + test('it does not prepopulate paths when cached capabilities route is unrelated to the current route', async function (assert) { + this.currentRouteNameStub.value('vault.cluster.secrets.secret'); + this.cacheCapabilityPaths('vault.cluster.settings', ['some/settings']); + await this.renderComponent(); + assert.dom(SELECTORS.pathByContainer(0)).hasValue(''); + }); + + test('it prepopulates paths when cached capabilities route equals current route', async function (assert) { + this.currentRouteNameStub.value('vault.cluster.secrets.secret'); + this.cacheCapabilityPaths('vault.cluster.secrets.secret', ['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']); + test('it prepopulates paths from longest matching parent route', async function (assert) { + // Cache paths for parent route + this.cacheCapabilityPaths('vault.cluster.secrets.backend.kv.secret', [ + 'kv/data/my-secret', + 'kv/metadata/my-secret', + ]); + this.cacheCapabilityPaths('vault.cluster.secrets.backend.kv', ['should/not/cache']); + // Current route is a child (e.g., secret.details) + this.currentRouteNameStub.value('vault.cluster.secrets.backend.kv.secret.details'); await this.renderComponent(); - assert.dom(SELECTORS.pathByContainer(0)).hasValue('path/one'); - assert.dom(SELECTORS.pathByContainer(1)).hasValue('path/two'); + assert.dom(SELECTORS.pathByContainer(0)).hasValue('kv/data/my-secret', 'uses parent paths'); + assert.dom(SELECTORS.pathByContainer(1)).hasValue('kv/metadata/my-secret', 'includes all parent paths'); 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(); + // All of these tests run with the current route stubbed and cached paths + module('when the flyout is prepopulated', function (hooks) { + hooks.beforeEach(function () { + this.cacheCapabilityPaths('vault.cluster.secrets.secret', ['super-secret/data']); + this.currentRouteNameStub.value('vault.cluster.secrets.secret'); + }); - // 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('paths from arg take precedence over capabilities service', async function (assert) { + this.policyPaths = ['super-explicit/path']; + await this.renderComponent(); + assert.dom(SELECTORS.pathByContainer(0)).hasValue('super-explicit/path'); + 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(); + test('it prepopulates with a single capability path', async function (assert) { + await this.renderComponent(); + assert.dom(SELECTORS.pathByContainer(0)).hasValue('super-secret/data'); + assert.dom(GENERAL.cardContainer()).exists({ count: 1 }); + }); - // 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 prepopulates with multiple capability paths', async function (assert) { + this.cacheCapabilityPaths('vault.cluster.secrets.secret', ['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 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 override user changes to a preset path on reopen', async function (assert) { + 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 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); + test('it does not override user capabilities selection for a preset path on reopen', async function (assert) { + 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) { + 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.cacheCapabilityPaths('vault.cluster.secrets.secret', ['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/unit/services/capabilities-test.js b/ui/tests/unit/services/capabilities-test.js index 8f29fdc408..38b901ed16 100644 --- a/ui/tests/unit/services/capabilities-test.js +++ b/ui/tests/unit/services/capabilities-test.js @@ -14,6 +14,7 @@ module('Unit | Service | capabilities', function (hooks) { hooks.beforeEach(function () { this.capabilities = this.owner.lookup('service:capabilities'); + this.checkCachedPaths = (route) => this.capabilities.routePathCache.get(route); this.generateResponse = ({ path, paths, capabilities }) => { if (path) { // "capabilities" is an array @@ -142,8 +143,9 @@ 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) { + test('fetch: it caches paths when routeForCache is provided', async function (assert) { const paths = ['/my/api/path', 'another/api/path']; + const route = 'vault.cluster.some-route'; this.server.post('/sys/capabilities-self', () => { return this.generateResponse({ @@ -152,33 +154,110 @@ module('Unit | Service | capabilities', function (hooks) { }); }); - 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'); + assert.strictEqual(this.capabilities.routePathCache.size, 0, 'cache is empty before fetch'); + await this.capabilities.fetch(paths, { routeForCache: route }); + assert.strictEqual(this.capabilities.routePathCache.size, 1, 'cache contains 1 route'); + const cachedPaths = this.checkCachedPaths(route); + assert.deepEqual(Array.from(cachedPaths), paths, 'cached paths match fetched paths'); }); - test('fetch: it replaces requestedPaths on each call', async function (assert) { - const firstPaths = ['/path/one', '/path/two']; - const secondPaths = ['/path/three']; - + test('fetch: it does not cache when routeForCache is not provided', async function (assert) { + const paths = ['/my/api/path']; this.server.post('/sys/capabilities-self', () => { - return this.generateResponse({ - paths: firstPaths, - capabilities: { '/path/one': ['read'], '/path/two': ['read'], '/path/three': ['read'] }, - }); + return this.generateResponse({ paths, capabilities: { '/my/api/path': ['read'] } }); }); + await this.capabilities.fetch(paths); + assert.strictEqual(this.capabilities.routePathCache.size, 0, 'cache remains empty'); + }); - await this.capabilities.fetch(firstPaths); - assert.strictEqual(this.capabilities.requestedPaths.size, 2, 'initially has 2 paths'); + test('cacheRoutePaths: it caches paths', async function (assert) { + const route = 'vault.cluster.some-route'; + const apiPaths = ['/my/api/path', 'another/api/path']; - 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'); + assert.strictEqual(this.capabilities.routePathCache.size, 0, 'routePathCache is empty before fetch'); + this.capabilities.cacheRoutePaths(route, apiPaths); + assert.strictEqual(this.capabilities.routePathCache.size, 1, 'routePathCache contains 1 item'); + const cachedPaths = this.checkCachedPaths(route); + assert.strictEqual(cachedPaths.size, 2, 'it stores 2 paths for the route'); + assert.true(cachedPaths.has('/my/api/path'), 'contains first path'); + assert.true(cachedPaths.has('another/api/path'), 'contains second path'); + }); + + test('cacheRoutePaths: it caches the paths for multiple routes', async function (assert) { + const routeA = 'vault.cluster.route-A'; + const pathsA = ['route/A/path']; + const routeB = 'vault.cluster.route-B'; + const pathsB = ['route/B/path']; + + this.capabilities.cacheRoutePaths(routeA, pathsA); + this.capabilities.cacheRoutePaths(routeB, pathsB); + assert.strictEqual(this.capabilities.routePathCache.size, 2, 'contains two items'); + assert.true(this.checkCachedPaths(routeA).has(pathsA[0]), 'contains routeA path'); + assert.true(this.checkCachedPaths(routeB).has(pathsB[0]), 'contains routeB path'); + }); + + test('cacheRoutePaths: it replaces cached paths if called with the same route', async function (assert) { + const route = 'vault.cluster.some-route'; + const firstCall = ['first/path']; + + this.capabilities.cacheRoutePaths(route, firstCall); + const initialCache = this.checkCachedPaths(route); + assert.strictEqual(initialCache.size, 1, 'cached paths contains 1 path'); + assert.true(initialCache.has(firstCall[0]), 'contains initial path'); + + const secondCall = ['second/path']; + this.capabilities.cacheRoutePaths(route, secondCall); + const secondCache = this.checkCachedPaths(route); + assert.strictEqual(secondCache.size, 1, 'second cache still contains 1 path'); + assert.true(secondCache.has(secondCall[0]), 'path is from second call'); + }); + + test('cacheRoutePaths: it deduplicates paths', async function (assert) { + const route = 'vault.cluster.some-route'; + const paths = ['same/path', 'same/path', 'almost/same/path']; + + this.capabilities.cacheRoutePaths(route, paths); + const cachedPaths = this.checkCachedPaths(route); + assert.strictEqual(cachedPaths.size, 2, 'routePathCache contains 2 items'); + assert.true(cachedPaths.has('same/path'), 'contains first path'); + assert.true(cachedPaths.has('almost/same/path'), 'contains second path'); + }); + + test('lookupRoutePaths: it returns exact route match', async function (assert) { + const route = 'vault.cluster.some-route'; + const pathSet = new Set(['apps/super-secret']); + this.capabilities.routePathCache.set(route, pathSet); + + const returnedPaths = await this.capabilities.lookupRoutePaths(route); + assert.deepEqual(returnedPaths, pathSet, 'returned paths equal original set'); + }); + + test('lookupRoutePaths: it returns longest matching route if multiple exist', async function (assert) { + const ancestor1 = 'vault.cluster.secrets'; + const ancestor2 = 'vault.cluster.secrets.backend.kv'; + const ancestor3 = 'vault.cluster.secrets.backend.kv.secret'; + const pathSet1 = new Set(['apps/']); + const pathSet2 = new Set(['apps/github-creds/']); + const pathSet3 = new Set(['apps/github-creds/api-tokens']); + this.capabilities.routePathCache.set(ancestor1, pathSet1); + this.capabilities.routePathCache.set(ancestor2, pathSet2); + this.capabilities.routePathCache.set(ancestor3, pathSet3); + + const returnedPaths = await this.capabilities.lookupRoutePaths( + 'vault.cluster.secrets.backend.kv.secret.details' + ); + assert.deepEqual(returnedPaths, pathSet3, 'it returns the paths that match the longest route'); + }); + + test('lookupRoutePaths: it returns null when no routes are cached', async function (assert) { + const returnedPaths = await this.capabilities.lookupRoutePaths('vault.cluster.some-route'); + assert.strictEqual(returnedPaths, null, 'returns null when cache is empty'); + }); + + test('lookupRoutePaths: it returns null when no matching routes exist', async function (assert) { + this.capabilities.routePathCache.set('vault.cluster.secrets', new Set(['secret/'])); + const returnedPaths = await this.capabilities.lookupRoutePaths('vault.cluster.settings.configure'); + assert.strictEqual(returnedPaths, null, 'returns null when no routes match'); }); test('fetchPathCapabilities: it makes request to capabilities-self and returns capabilities for single path', async function (assert) { @@ -221,6 +300,18 @@ module('Unit | Service | capabilities', function (hooks) { ); }); + test('pathsForList: it returns multiple PATH_MAP keys', async function (assert) { + const keys = ['kubernetesRole', 'kubernetesCreds']; + const params = { backend: 'k8', name: 'my-role' }; + const paths = this.capabilities.pathsForList(keys, params); + assert.deepEqual(paths, ['k8/role/my-role', 'k8/creds/my-role'], 'computes all paths'); + }); + + test('pathsForList: it handles an empty array', async function (assert) { + const paths = this.capabilities.pathsForList([], {}); + assert.deepEqual(paths, [], 'returns empty array'); + }); + test('for: it should fetch capabilities for single path using pathFor and fetchPathCapabilities methods', async function (assert) { const pathForStub = sinon.spy(this.capabilities, 'pathFor'); const fetchPathCapabilitiesStub = sinon.stub(this.capabilities, 'fetchPathCapabilities').resolves();