Merge remote-tracking branch 'remotes/from/ce/main'
Some checks are pending
build / setup (push) Waiting to run
build / Check ce/* Pull Requests (push) Blocked by required conditions
build / ui (push) Blocked by required conditions
build / artifacts-ce (push) Blocked by required conditions
build / artifacts-ent (push) Blocked by required conditions
build / hcp-image (push) Blocked by required conditions
build / test (push) Blocked by required conditions
build / test-hcp-image (push) Blocked by required conditions
build / completed-successfully (push) Blocked by required conditions
CI / setup (push) Waiting to run
CI / Run Autopilot upgrade tool (push) Blocked by required conditions
CI / Run Go tests (push) Blocked by required conditions
CI / Run Go tests tagged with testonly (push) Blocked by required conditions
CI / Run Go tests with data race detection (push) Blocked by required conditions
CI / Run Go tests with FIPS configuration (push) Blocked by required conditions
CI / Test UI (push) Blocked by required conditions
CI / tests-completed (push) Blocked by required conditions
Run linters / Setup (push) Waiting to run
Run linters / Deprecated functions (push) Blocked by required conditions
Run linters / Code checks (push) Blocked by required conditions
Run linters / Protobuf generate delta (push) Blocked by required conditions
Run linters / Format (push) Blocked by required conditions
Run linters / Semgrep (push) Waiting to run
Check Copywrite Headers / copywrite (push) Waiting to run
Security Scan / scan (push) Waiting to run

This commit is contained in:
hc-github-team-secure-vault-core 2026-02-16 00:05:37 +00:00
commit c46306dfe4
8 changed files with 349 additions and 106 deletions

View file

@ -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<string>([]);
/*
* API path caching for <CodeGeneratorPolicyFlyout />
* Cache API paths when capabilities are fetched during route model hooks
* so the flyout can prefill with relevant policy paths.
*/
@tracked routePathCache = new Map<string, Set<string>>();
// 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 <CodeGeneratorPolicyFlyout /> 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<CapabilitiesMap> {
this.requestedPaths = new Set(paths);
async fetch(paths: string[], fetchOptions: CapabilityFetchOptions = {}): Promise<CapabilitiesMap> {
// 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<T>(key: keyof typeof PATH_MAP, params?: T) {
async for<T>(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;
}

View file

@ -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<Args> {
@ -42,6 +45,10 @@ export default class CodeGeneratorPolicyFlyout extends Component<Args> {
@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<Args> {
@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<Args> {
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;
}
}

View file

@ -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

View file

@ -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)
);
}

View file

@ -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 {

View file

@ -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,

View file

@ -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`<CodeGenerator::Policy::Flyout @onClose={{this.onClose}} />`);
await render(
hbs`<CodeGenerator::Policy::Flyout @onClose={{this.onClose}} @policyPaths={{this.policyPaths}} />`
);
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`<Hds::Dropdown as |D|>
<D.ToggleButton @text="Toolbox" data-test-dropdown="Toolbox" />
@ -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);
});
});
});
});

View file

@ -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();