From 3fdafc781a21167901560ed5a8ff7de3d283ed3f Mon Sep 17 00:00:00 2001 From: Chelsea Shaw Date: Thu, 12 Sep 2024 09:21:58 -0500 Subject: [PATCH] add capabilities service (from #28168) --- ui/app/adapters/capabilities.js | 57 +++- ui/app/serializers/capabilities.js | 21 +- ui/app/services/capabilities.ts | 120 +++++++ ui/tests/unit/adapters/capabilities-test.js | 172 +++++++--- ui/tests/unit/services/capabilities-test.js | 345 ++++++++++++++++++++ ui/types/vault/services/store.d.ts | 7 +- 6 files changed, 656 insertions(+), 66 deletions(-) create mode 100644 ui/app/services/capabilities.ts create mode 100644 ui/tests/unit/services/capabilities-test.js diff --git a/ui/app/adapters/capabilities.js b/ui/app/adapters/capabilities.js index 2d8a7c2e478a..6b1b0a621066 100644 --- a/ui/app/adapters/capabilities.js +++ b/ui/app/adapters/capabilities.js @@ -6,24 +6,31 @@ import AdapterError from '@ember-data/adapter/error'; import { set } from '@ember/object'; import ApplicationAdapter from './application'; -import { sanitizePath } from 'core/utils/sanitize-path'; +import { sanitizePath, sanitizeStart } from 'core/utils/sanitize-path'; -export default ApplicationAdapter.extend({ +export default class CapabilitiesAdapter extends ApplicationAdapter { pathForType() { return 'capabilities-self'; - }, + } - formatPaths(path) { + /* + 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. + adding "relativeNamespace" to the path and/or "this.namespaceService.userRootNamespace" + to the request header ensures we are querying capabilities-self in the user's root namespace, + which is where they are most likely to have their policy/permissions. + */ + _formatPath(path) { const { relativeNamespace } = this.namespaceService; if (!relativeNamespace) { - return [path]; + return path; } // ensure original path doesn't have leading slash - return [`${relativeNamespace}/${path.replace(/^\//, '')}`]; - }, + return `${relativeNamespace}/${sanitizeStart(path)}`; + } async findRecord(store, type, id) { - const paths = this.formatPaths(id); + const paths = [this._formatPath(id)]; return this.ajax(this.buildURL(type), 'POST', { data: { paths }, namespace: sanitizePath(this.namespaceService.userRootNamespace), @@ -33,7 +40,7 @@ export default ApplicationAdapter.extend({ } throw e; }); - }, + } queryRecord(store, type, query) { const { id } = query; @@ -44,5 +51,33 @@ export default ApplicationAdapter.extend({ resp.path = id; return resp; }); - }, -}); + } + + query(store, type, query) { + const pathMap = query?.paths.reduce((mapping, path) => { + const withNs = this._formatPath(path); + if (withNs) { + mapping[withNs] = path; + } + return mapping; + }, {}); + + return this.ajax(this.buildURL(type), 'POST', { + data: { paths: Object.keys(pathMap) }, + namespace: sanitizePath(this.namespaceService.userRootNamespace), + }) + .then((queryResult) => { + if (queryResult) { + // send the pathMap with the response so the serializer can normalize the paths to be relative to the namespace + queryResult.pathMap = pathMap; + } + return queryResult; + }) + .catch((e) => { + if (e instanceof AdapterError) { + set(e, 'policyPath', 'sys/capabilities-self'); + } + throw e; + }); + } +} diff --git a/ui/app/serializers/capabilities.js b/ui/app/serializers/capabilities.js index 18b2820cb63e..045407811495 100644 --- a/ui/app/serializers/capabilities.js +++ b/ui/app/serializers/capabilities.js @@ -9,14 +9,21 @@ export default ApplicationSerializer.extend({ primaryKey: 'path', normalizeResponse(store, primaryModelClass, payload, id, requestType) { - // queryRecord will already have set this, and we won't have an id here - if (id) { - payload.path = id; + let response; + // queryRecord will already have set path, and we won't have an id here + if (id) payload.path = id; + + if (requestType === 'query') { + // each key on the response is a path with an array of capabilities as its value + response = Object.keys(payload.data).map((fullPath) => { + // we use pathMap to normalize a namespace-prefixed path back to the relative path + // this is okay because we clear capabilities when moving between namespaces + const path = payload.pathMap ? payload.pathMap[fullPath] : fullPath; + return { capabilities: payload.data[fullPath], path }; + }); + } else { + response = { ...payload.data, path: payload.path }; } - const response = { - ...payload.data, - path: payload.path, - }; return this._super(store, primaryModelClass, response, id, requestType); }, diff --git a/ui/app/services/capabilities.ts b/ui/app/services/capabilities.ts new file mode 100644 index 000000000000..813b0211aa6f --- /dev/null +++ b/ui/app/services/capabilities.ts @@ -0,0 +1,120 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Service, { service } from '@ember/service'; +import { assert } from '@ember/debug'; + +import type AdapterError from '@ember-data/adapter/error'; +import type CapabilitiesModel from 'vault/vault/models/capabilities'; +import type StoreService from 'vault/services/store'; + +interface Capabilities { + canCreate: boolean; + canDelete: boolean; + canList: boolean; + canPatch: boolean; + canRead: boolean; + canSudo: boolean; + canUpdate: boolean; +} + +interface MultipleCapabilities { + [key: string]: Capabilities; +} + +export default class CapabilitiesService extends Service { + @service declare readonly store: StoreService; + + async request(query: { paths?: string[]; path?: string }) { + if (query?.paths) { + const { paths } = query; + return this.store.query('capabilities', { paths }); + } + if (query?.path) { + const { path } = query; + const storeData = await this.store.peekRecord('capabilities', path); + return storeData ? storeData : this.store.findRecord('capabilities', path); + } + return assert('query object must contain "paths" or "path" key', false); + } + + async fetchMultiplePaths(paths: string[]): Promise { + // if the request to capabilities-self fails, silently catch + // all of path capabilities default to "true" + const resp: CapabilitiesModel[] = await this.request({ paths }).catch(() => []); + return paths.reduce((obj: MultipleCapabilities, apiPath: string) => { + // path is the model's primaryKey (id) + const model = resp.find((m) => m.path === apiPath); + if (model) { + const { canCreate, canDelete, canList, canPatch, canRead, canSudo, canUpdate } = model; + obj[apiPath] = { canCreate, canDelete, canList, canPatch, canRead, canSudo, canUpdate }; + } else { + // default to true if there is a problem fetching the model + // since we can rely on the API to gate as a fallback + obj[apiPath] = { + canCreate: true, + canDelete: true, + canList: true, + canPatch: true, + canRead: true, + canSudo: true, + canUpdate: true, + }; + } + return obj; + }, {}); + } + + /* + this method returns all of the capabilities for a singular path + */ + fetchPathCapabilities(path: string): Promise | AdapterError { + try { + return this.request({ path }); + } catch (error) { + return error; + } + } + + /* + internal method for specific capability checks below + checks the capability model for the passed capability, ie "canRead" + */ + async _fetchSpecificCapability( + path: string, + capability: string + ): Promise | AdapterError { + try { + const capabilities = await this.request({ path }); + return capabilities[capability]; + } catch (e) { + return e; + } + } + + canRead(path: string) { + try { + return this._fetchSpecificCapability(path, 'canRead'); + } catch (e) { + return e; + } + } + + canUpdate(path: string) { + try { + return this._fetchSpecificCapability(path, 'canUpdate'); + } catch (e) { + return e; + } + } + + canPatch(path: string) { + try { + return this._fetchSpecificCapability(path, 'canPatch'); + } catch (e) { + return e; + } + } +} diff --git a/ui/tests/unit/adapters/capabilities-test.js b/ui/tests/unit/adapters/capabilities-test.js index 0cd83a000796..86bb1feed7c2 100644 --- a/ui/tests/unit/adapters/capabilities-test.js +++ b/ui/tests/unit/adapters/capabilities-test.js @@ -10,61 +10,141 @@ import { setupTest } from 'ember-qunit'; module('Unit | Adapter | capabilities', function (hooks) { setupTest(hooks); - test('calls the correct url', function (assert) { - let url, method, options; - const adapter = this.owner.factoryFor('adapter:capabilities').create({ - ajax: (...args) => { - [url, method, options] = args; - return resolve(); - }, + module('findRecord', function () { + test('it calls the correct url', function (assert) { + let url, method, options; + const adapter = this.owner.factoryFor('adapter:capabilities').create({ + ajax: (...args) => { + [url, method, options] = args; + return resolve(); + }, + }); + + adapter.findRecord(null, 'capabilities', 'foo'); + assert.strictEqual(url, '/v1/sys/capabilities-self', 'calls the correct URL'); + assert.deepEqual(options.data, { paths: ['foo'] }, 'data params OK'); + assert.strictEqual(method, 'POST', 'method OK'); }); - adapter.findRecord(null, 'capabilities', 'foo'); - assert.strictEqual(url, '/v1/sys/capabilities-self', 'calls the correct URL'); - assert.deepEqual({ paths: ['foo'] }, options.data, 'data params OK'); - assert.strictEqual(method, 'POST', 'method OK'); - }); + test('enterprise calls the correct url within namespace when userRoot = root', function (assert) { + const namespaceSvc = this.owner.lookup('service:namespace'); + namespaceSvc.setNamespace('admin'); - test('enterprise calls the correct url within namespace when userRoot = root', function (assert) { - const namespaceSvc = this.owner.lookup('service:namespace'); - namespaceSvc.setNamespace('admin'); + let url, method, options; + const adapter = this.owner.factoryFor('adapter:capabilities').create({ + ajax: (...args) => { + [url, method, options] = args; + return resolve(); + }, + }); - let url, method, options; - const adapter = this.owner.factoryFor('adapter:capabilities').create({ - ajax: (...args) => { - [url, method, options] = args; - return resolve(); - }, + adapter.findRecord(null, 'capabilities', 'foo'); + assert.strictEqual(url, '/v1/sys/capabilities-self', 'calls the correct URL'); + assert.deepEqual(options.data, { paths: ['admin/foo'] }, 'data params prefix paths with namespace'); + assert.strictEqual(options.namespace, '', 'sent with root namespace'); + assert.strictEqual(method, 'POST', 'method OK'); }); - adapter.findRecord(null, 'capabilities', 'foo'); - assert.strictEqual(url, '/v1/sys/capabilities-self', 'calls the correct URL'); - assert.deepEqual({ paths: ['admin/foo'] }, options.data, 'data params prefix paths with namespace'); - assert.strictEqual(options.namespace, '', 'sent with root namespace'); - assert.strictEqual(method, 'POST', 'method OK'); + test('enterprise calls the correct url within namespace when userRoot is not root', function (assert) { + const namespaceSvc = this.owner.lookup('service:namespace'); + const auth = this.owner.lookup('service:auth'); + namespaceSvc.setNamespace('admin/bar/baz'); + // Set user root namespace + auth.setCluster('1'); + auth.set('tokens', ['vault-_root_☃1']); + auth.setTokenData('vault-_root_☃1', { + userRootNamespace: 'admin/bar', + backend: { mountPath: 'token' }, + }); + + let url, method, options; + const adapter = this.owner.factoryFor('adapter:capabilities').create({ + ajax: (...args) => { + [url, method, options] = args; + return resolve(); + }, + }); + + adapter.findRecord(null, 'capabilities', 'foo'); + assert.strictEqual(url, '/v1/sys/capabilities-self', 'calls the correct URL'); + assert.deepEqual( + options.data, + { paths: ['baz/foo'] }, + 'data params prefix path with relative namespace' + ); + assert.strictEqual(options.namespace, 'admin/bar', 'sent with root namespace'); + assert.strictEqual(method, 'POST', 'method OK'); + }); }); - test('enterprise calls the correct url within namespace when userRoot is not root', function (assert) { - const namespaceSvc = this.owner.lookup('service:namespace'); - const auth = this.owner.lookup('service:auth'); - namespaceSvc.setNamespace('admin/bar/baz'); - // Set user root namespace - auth.setCluster('1'); - auth.set('tokens', ['vault-_root_☃1']); - auth.setTokenData('vault-_root_☃1', { userRootNamespace: 'admin/bar', backend: { mountPath: 'token' } }); - - let url, method, options; - const adapter = this.owner.factoryFor('adapter:capabilities').create({ - ajax: (...args) => { - [url, method, options] = args; - return resolve(); - }, + module('query', function () { + test('it calls the correct url', function (assert) { + let url, method, options; + const adapter = this.owner.factoryFor('adapter:capabilities').create({ + ajax: (...args) => { + [url, method, options] = args; + return resolve(); + }, + }); + + adapter.query(null, 'capabilities', { paths: ['foo', 'my/path'] }); + assert.strictEqual(url, '/v1/sys/capabilities-self', 'calls the correct URL'); + assert.deepEqual(options.data, { paths: ['foo', 'my/path'] }, 'data params OK'); + assert.strictEqual(method, 'POST', 'method OK'); }); - adapter.findRecord(null, 'capabilities', 'foo'); - assert.strictEqual(url, '/v1/sys/capabilities-self', 'calls the correct URL'); - assert.deepEqual({ paths: ['baz/foo'] }, options.data, 'data params prefix path with relative namespace'); - assert.strictEqual(options.namespace, 'admin/bar', 'sent with root namespace'); - assert.strictEqual(method, 'POST', 'method OK'); + test('enterprise calls the correct url within namespace when userRoot = root', function (assert) { + const namespaceSvc = this.owner.lookup('service:namespace'); + namespaceSvc.setNamespace('admin'); + + let url, method, options; + const adapter = this.owner.factoryFor('adapter:capabilities').create({ + ajax: (...args) => { + [url, method, options] = args; + return resolve(); + }, + }); + + adapter.query(null, 'capabilities', { paths: ['foo', 'my/path'] }); + assert.strictEqual(url, '/v1/sys/capabilities-self', 'calls the correct URL'); + assert.deepEqual( + options.data, + { paths: ['admin/foo', 'admin/my/path'] }, + 'data params prefix paths with namespace' + ); + assert.strictEqual(options.namespace, '', 'sent with root namespace'); + assert.strictEqual(method, 'POST', 'method OK'); + }); + + test('enterprise calls the correct url within namespace when userRoot is not root', function (assert) { + const namespaceSvc = this.owner.lookup('service:namespace'); + const auth = this.owner.lookup('service:auth'); + namespaceSvc.setNamespace('admin/bar/baz'); + // Set user root namespace + auth.setCluster('1'); + auth.set('tokens', ['vault-_root_☃1']); + auth.setTokenData('vault-_root_☃1', { + userRootNamespace: 'admin/bar', + backend: { mountPath: 'token' }, + }); + + let url, method, options; + const adapter = this.owner.factoryFor('adapter:capabilities').create({ + ajax: (...args) => { + [url, method, options] = args; + return resolve(); + }, + }); + + adapter.query(null, 'capabilities', { paths: ['foo', 'my/path'] }); + assert.strictEqual(url, '/v1/sys/capabilities-self', 'calls the correct URL'); + assert.deepEqual( + options.data, + { paths: ['baz/foo', 'baz/my/path'] }, + 'data params prefix path with relative namespace' + ); + assert.strictEqual(options.namespace, 'admin/bar', 'sent with root namespace'); + assert.strictEqual(method, 'POST', 'method OK'); + }); }); }); diff --git a/ui/tests/unit/services/capabilities-test.js b/ui/tests/unit/services/capabilities-test.js new file mode 100644 index 000000000000..0964db1d43f2 --- /dev/null +++ b/ui/tests/unit/services/capabilities-test.js @@ -0,0 +1,345 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; + +module('Unit | Service | capabilities', function (hooks) { + setupTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.capabilities = this.owner.lookup('service:capabilities'); + this.store = this.owner.lookup('service:store'); + this.generateResponse = ({ path, paths, capabilities }) => { + if (path) { + // "capabilities" is an array + return { + request_id: '6cc7a484-921a-a730-179c-eaf6c6fbe97e', + data: { + capabilities, + [path]: capabilities, + }, + }; + } + if (paths) { + // "capabilities" is an object, paths are keys and values are array of capabilities + const data = paths.reduce((obj, path) => { + obj[path] = capabilities[path]; + return obj; + }, {}); + return { + request_id: '6cc7a484-921a-a730-179c-eaf6c6fbe97e', + data, + }; + } + }; + }); + + module('general methods', function () { + test('request: it makes request to capabilities-self with path param', function (assert) { + const path = '/my/api/path'; + const expectedPayload = { paths: [path] }; + this.server.post('/sys/capabilities-self', (schema, req) => { + const actual = JSON.parse(req.requestBody); + assert.true(true, 'request made to capabilities-self'); + assert.propEqual(actual, expectedPayload, `request made with path: ${JSON.stringify(actual)}`); + return this.generateResponse({ path, capabilities: ['read'] }); + }); + this.capabilities.request({ path }); + }); + + test('request: it makes request to capabilities-self with paths param', function (assert) { + const paths = ['/my/api/path', 'another/api/path']; + const expectedPayload = { paths }; + this.server.post('/sys/capabilities-self', (schema, req) => { + const actual = JSON.parse(req.requestBody); + assert.true(true, 'request made to capabilities-self'); + assert.propEqual(actual, expectedPayload, `request made with path: ${JSON.stringify(actual)}`); + return this.generateResponse({ + paths, + capabilities: { '/my/api/path': ['read'], 'another/api/path': ['read'] }, + }); + }); + this.capabilities.request({ paths }); + }); + }); + + test('fetchPathCapabilities: it makes request to capabilities-self with path param', function (assert) { + const path = '/my/api/path'; + const expectedPayload = { paths: [path] }; + this.server.post('/sys/capabilities-self', (schema, req) => { + const actual = JSON.parse(req.requestBody); + assert.true(true, 'request made to capabilities-self'); + assert.propEqual(actual, expectedPayload, `request made with path: ${JSON.stringify(actual)}`); + return this.generateResponse({ path, capabilities: ['read'] }); + }); + this.capabilities.fetchPathCapabilities(path); + }); + + test('fetchMultiplePaths: it makes request to capabilities-self with paths param', async function (assert) { + const paths = ['/my/api/path', 'another/api/path']; + const expectedPayload = { paths }; + this.server.post('/sys/capabilities-self', (schema, req) => { + const actual = JSON.parse(req.requestBody); + assert.true(true, 'request made to capabilities-self'); + assert.propEqual(actual, expectedPayload, `request made with path: ${JSON.stringify(actual)}`); + return this.generateResponse({ + paths, + capabilities: { '/my/api/path': ['read', 'list'], 'another/api/path': ['read', 'delete'] }, + }); + }); + const actual = await this.capabilities.fetchMultiplePaths(paths); + const expected = { + '/my/api/path': { + canCreate: false, + canDelete: false, + canList: true, + canPatch: false, + canRead: true, + canSudo: false, + canUpdate: false, + }, + 'another/api/path': { + canCreate: false, + canDelete: true, + canList: false, + canPatch: false, + canRead: true, + canSudo: false, + canUpdate: false, + }, + }; + assert.propEqual(actual, expected, `it returns expected response: ${JSON.stringify(actual)}`); + }); + + test('fetchMultiplePaths: it defaults to true if the capabilities request fails', async function (assert) { + // don't stub endpoint which causes request to fail + const paths = ['/my/api/path', 'another/api/path']; + const actual = await this.capabilities.fetchMultiplePaths(paths); + const expected = { + '/my/api/path': { + canCreate: true, + canDelete: true, + canList: true, + canPatch: true, + canRead: true, + canSudo: true, + canUpdate: true, + }, + 'another/api/path': { + canCreate: true, + canDelete: true, + canList: true, + canPatch: true, + canRead: true, + canSudo: true, + canUpdate: true, + }, + }; + assert.propEqual(actual, expected, `it returns expected response: ${JSON.stringify(actual)}`); + }); + + test('fetchMultiplePaths: it defaults to true if no model is found', async function (assert) { + const paths = ['/my/api/path', 'another/api/path']; + const expectedPayload = { paths }; + this.server.post('/sys/capabilities-self', (schema, req) => { + const actual = JSON.parse(req.requestBody); + assert.true(true, 'request made to capabilities-self'); + assert.propEqual(actual, expectedPayload, `request made with path: ${JSON.stringify(actual)}`); + return this.generateResponse({ + paths: ['/my/api/path'], + capabilities: { '/my/api/path': ['read', 'list'] }, + }); + }); + const actual = await this.capabilities.fetchMultiplePaths(paths); + const expected = { + '/my/api/path': { + canCreate: false, + canDelete: false, + canList: true, + canPatch: false, + canRead: true, + canSudo: false, + canUpdate: false, + }, + 'another/api/path': { + canCreate: true, + canDelete: true, + canList: true, + canPatch: true, + canRead: true, + canSudo: true, + canUpdate: true, + }, + }; + assert.propEqual(actual, expected, `it returns expected response: ${JSON.stringify(actual)}`); + }); + + module('specific methods', function () { + const path = '/my/api/path'; + [ + { + capabilities: ['read'], + expectedRead: true, // expected computed properties based on response + expectedUpdate: false, + expectedPatch: false, + }, + { + capabilities: ['update'], + expectedRead: false, + expectedUpdate: true, + expectedPatch: false, + }, + { + capabilities: ['patch'], + expectedRead: false, + expectedUpdate: false, + expectedPatch: true, + }, + { + capabilities: ['deny'], + expectedRead: false, + expectedUpdate: false, + expectedPatch: false, + }, + { + capabilities: ['read', 'update'], + expectedRead: true, + expectedUpdate: true, + expectedPatch: false, + }, + ].forEach(({ capabilities, expectedRead, expectedUpdate, expectedPatch }) => { + test(`canRead returns expected value for "${capabilities.join(', ')}"`, async function (assert) { + this.server.post('/sys/capabilities-self', () => { + return this.generateResponse({ path, capabilities }); + }); + + const response = await this.capabilities.canRead(path); + assert[expectedRead](response, `canRead returns ${expectedRead}`); + }); + + test(`canUpdate returns expected value for "${capabilities.join(', ')}"`, async function (assert) { + this.server.post('/sys/capabilities-self', () => { + return this.generateResponse({ path, capabilities }); + }); + const response = await this.capabilities.canUpdate(path); + assert[expectedUpdate](response, `canUpdate returns ${expectedUpdate}`); + }); + + test(`canPatch returns expected value for "${capabilities.join(', ')}"`, async function (assert) { + this.server.post('/sys/capabilities-self', () => { + return this.generateResponse({ path, capabilities }); + }); + const response = await this.capabilities.canPatch(path); + assert[expectedPatch](response, `canPatch returns ${expectedPatch}`); + }); + }); + }); + + module('within namespace', function (hooks) { + // capabilities within namespaces are queried at the user's root namespace with a path that includes + // the relative namespace. The capabilities record is saved at the path without the namespace. + hooks.beforeEach(function () { + this.nsSvc = this.owner.lookup('service:namespace'); + this.nsSvc.path = 'ns1'; + this.store.unloadAll('capabilities'); + }); + + test('fetchPathCapabilities works as expected', async function (assert) { + const ns = this.nsSvc.path; + const path = '/my/api/path'; + const expectedAttrs = { + // capabilities has ID at non-namespaced path + id: path, + canCreate: false, + canDelete: false, + canList: true, + canPatch: false, + canRead: true, + canSudo: false, + canUpdate: false, + }; + this.server.post('/sys/capabilities-self', (schema, req) => { + const actual = JSON.parse(req.requestBody); + assert.strictEqual(req.url, '/v1/sys/capabilities-self', 'request made to capabilities-self'); + assert.propEqual( + actual.paths, + [`${ns}/my/api/path`], + `request made with path: ${JSON.stringify(actual)}` + ); + return this.generateResponse({ + path: `${ns}${path}`, + capabilities: ['read', 'list'], + }); + }); + const actual = await this.capabilities.fetchPathCapabilities(path); + assert.strictEqual(this.store.peekAll('capabilities').length, 1, 'adds 1 record'); + + Object.keys(expectedAttrs).forEach(function (key) { + assert.strictEqual( + actual[key], + expectedAttrs[key], + `record has expected value for ${key}: ${actual[key]}` + ); + }); + }); + + test('fetchMultiplePaths works as expected', async function (assert) { + const ns = this.nsSvc.path; + const paths = ['/my/api/path', '/another/api/path']; + const expectedPayload = paths.map((p) => `${ns}${p}`); + + this.server.post('/sys/capabilities-self', (schema, req) => { + const actual = JSON.parse(req.requestBody); + assert.strictEqual(req.url, '/v1/sys/capabilities-self', 'request made to capabilities-self'); + assert.propEqual(actual.paths, expectedPayload, `request made with paths: ${JSON.stringify(actual)}`); + const resp = this.generateResponse({ + paths: expectedPayload, + capabilities: { + [`${ns}/my/api/path`]: ['read', 'list'], + [`${ns}/another/api/path`]: ['update', 'patch'], + }, + }); + return resp; + }); + const actual = await this.capabilities.fetchMultiplePaths(paths); + const expected = { + '/my/api/path': { + canCreate: false, + canDelete: false, + canList: true, + canPatch: false, + canRead: true, + canSudo: false, + canUpdate: false, + }, + '/another/api/path': { + canCreate: false, + canDelete: false, + canList: false, + canPatch: true, + canRead: false, + canSudo: false, + canUpdate: true, + }, + }; + assert.deepEqual(actual, expected, 'method returns expected response'); + assert.strictEqual(this.store.peekAll('capabilities').length, 2, 'adds 2 records'); + Object.keys(expected).forEach((path) => { + const record = this.store.peekRecord('capabilities', path); + assert.strictEqual(record.id, path, `record exists with id: ${record.id}`); + Object.keys(expected[path]).forEach((attr) => { + assert.strictEqual( + record[attr], + expected[path][attr], + `record has correct value for ${attr}: ${record[attr]}` + ); + }); + }); + }); + }); +}); diff --git a/ui/types/vault/services/store.d.ts b/ui/types/vault/services/store.d.ts index af201ccb5547..9abeb08ee75e 100644 --- a/ui/types/vault/services/store.d.ts +++ b/ui/types/vault/services/store.d.ts @@ -8,9 +8,12 @@ import Store, { RecordArray } from '@ember-data/store'; export default class StoreService extends Store { lazyPaginatedQuery( modelName: string, - query: Object, - options?: { adapterOptions: Object } + query: object, + options?: { adapterOptions: object } ): Promise; clearDataset(modelName: string); + findRecord(modelName: string, path: string); + peekRecord(modelName: string, path: string); + query(modelName: string, query: object); }