UI: Implement policy generator in kv v2 routes (#11798) (#11813)

* add support for initializing with capability paths

* only render flyout for enterprise versions

* update PolicyStanza to support object

* add policy generator to kv

* only set preset stanzas if no changes have been made

* add test coverage for kv adding policy generate to page headers

* add community test

* add test coverage to capabilities service

* add changelog

* add close callback

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
This commit is contained in:
Vault Automation 2026-01-16 11:45:01 -07:00 committed by GitHub
parent 062537e1fe
commit 7a29044ea4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 447 additions and 103 deletions

3
changelog/_11798.txt Normal file
View file

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

View file

@ -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<string>([]);
/*
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<CapabilitiesMap> {
this.requestedPaths = new Set(paths);
const payload = { paths: paths.map((path) => this.relativeNamespacePath(path)) };
try {

View file

@ -3,102 +3,109 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Button
@icon="file-plus"
@text="Generate policy"
@color="secondary"
@iconPosition="trailing"
{{on "click" (fn (mut this.showFlyout) true)}}
data-test-button="Generate policy"
/>
{{#if this.version.isEnterprise}}
{{#if (has-block "customTrigger")}}
{{! Passes the open flyout action to any yielded component }}
{{yield this.openFlyout to="customTrigger"}}
{{else}}
<Hds::Button
@icon="file-plus"
@text="Generate policy"
@color="secondary"
@iconPosition="trailing"
{{on "click" this.openFlyout}}
data-test-button="Generate policy"
/>
{{/if}}
{{#if this.showFlyout}}
<Hds::Flyout id="policy-generator-flyout" @onClose={{this.onClose}} @size="large" data-test-flyout as |M|>
<M.Header tag="h1" @icon="shield-check">
Policy generator
</M.Header>
{{#if this.showFlyout}}
<Hds::Flyout id="policy-generator-flyout" @onClose={{this.closeFlyout}} @size="large" data-test-flyout as |M|>
<M.Header tag="h1" @icon="shield-check">
Policy generator
</M.Header>
<M.Body>
<Hds::Form id="flyout-policy-form" {{on "submit" (perform this.onSave)}} data-test-policy-form as |FORM|>
{{#if this.errorMessage}}
<FORM.Section @isFullWidth={{true}}>
<MessageError @errorMessage={{this.errorMessage}} />
</FORM.Section>
{{/if}}
<M.Body>
<Hds::Form id="flyout-policy-form" {{on "submit" (perform this.onSave)}} data-test-policy-form as |FORM|>
{{#if this.errorMessage}}
<FORM.Section @isFullWidth={{true}}>
<MessageError @errorMessage={{this.errorMessage}} />
<Hds::Form::TextInput::Field
name="name"
@value={{this.policyName}}
@isInvalid={{this.validationError "name"}}
placeholder="Enter the policy name"
autocomplete="off"
spellcheck="false"
data-test-input="name"
{{on "input" this.handleNameInput}}
as |F|
>
<F.Label>Policy name</F.Label>
{{#if (this.validationError "name")}}
<F.Error data-test-validation-error="name">{{this.validationError "name"}}</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
</FORM.Section>
{{/if}}
<FORM.Section @isFullWidth={{true}}>
<Hds::Form::TextInput::Field
name="name"
@value={{this.policyName}}
@isInvalid={{this.validationError "name"}}
placeholder="Enter the policy name"
autocomplete="off"
spellcheck="false"
data-test-input="name"
{{on "input" this.handleNameInput}}
as |F|
>
<F.Label>Policy name</F.Label>
{{#if (this.validationError "name")}}
<F.Error data-test-validation-error="name">{{this.validationError "name"}}</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
</FORM.Section>
<FORM.Section @isFullWidth={{true}} as |FS|>
<FS.Header>
<Hds::Text::Body @size="200" @weight="semibold" @tag="h2">Policy rules</Hds::Text::Body>
<Hds::Text::Body @tag="p">
Use
<Hds::Text::Code class="code-in-text">*</Hds::Text::Code>
for wildcard matching.
</Hds::Text::Body>
</FS.Header>
<FORM.Section @isFullWidth={{true}} as |FS|>
<FS.Header>
<Hds::Text::Body @size="200" @weight="semibold" @tag="h2">Policy rules</Hds::Text::Body>
<Hds::Text::Body @tag="p">
Use
<Hds::Text::Code class="code-in-text">*</Hds::Text::Code>
for wildcard matching.
</Hds::Text::Body>
</FS.Header>
<CodeGenerator::Policy::Builder
@policyName={{this.policyName}}
@onPolicyChange={{this.handlePolicyChange}}
@stanzas={{this.stanzas}}
data-test-field="visual editor"
/>
</FORM.Section>
<CodeGenerator::Policy::Builder
@policyName={{this.policyName}}
@onPolicyChange={{this.handlePolicyChange}}
@stanzas={{this.stanzas}}
data-test-field="visual editor"
<FORM.Separator />
<FORM.Section @isFullWidth={{true}}>
<Hds::Accordion @size="medium" as |A|>
<A.Item data-test-accordion="Automation snippets">
<:toggle>Automation snippets</:toggle>
<:content>
<CodeGenerator::AutomationSnippets
@cliArgs={{this.snippetArgs.cli}}
@tfvpArgs={{this.snippetArgs.terraform}}
/>
</:content>
</A.Item>
</Hds::Accordion>
</FORM.Section>
</Hds::Form>
</M.Body>
<M.Footer as |F|>
<Hds::ButtonSet>
<Hds::Button
@text="Save"
@icon={{if this.onSave.isRunning "loading"}}
type="submit"
form="flyout-policy-form"
disabled={{this.onSave.isRunning}}
data-test-submit
/>
</FORM.Section>
<FORM.Separator />
<FORM.Section @isFullWidth={{true}}>
<Hds::Accordion @size="medium" as |A|>
<A.Item data-test-accordion="Automation snippets">
<:toggle>Automation snippets</:toggle>
<:content>
<CodeGenerator::AutomationSnippets
@cliArgs={{this.snippetArgs.cli}}
@tfvpArgs={{this.snippetArgs.terraform}}
/>
</:content>
</A.Item>
</Hds::Accordion>
</FORM.Section>
</Hds::Form>
</M.Body>
<M.Footer as |F|>
<Hds::ButtonSet>
<Hds::Button
@text="Save"
@icon={{if this.onSave.isRunning "loading"}}
type="submit"
form="flyout-policy-form"
disabled={{this.onSave.isRunning}}
data-test-submit
/>
<Hds::Button
@text="Cancel"
@color="secondary"
disabled={{this.onSave.isRunning}}
{{on "click" F.close}}
data-test-cancel
/>
</Hds::ButtonSet>
</M.Footer>
</Hds::Flyout>
<Hds::Button
@text="Cancel"
@color="secondary"
disabled={{this.onSave.isRunning}}
{{on "click" F.close}}
data-test-cancel
/>
</Hds::ButtonSet>
</M.Footer>
</Hds::Flyout>
{{/if}}
{{/if}}

View file

@ -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<Args> {
@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() {

View file

@ -17,7 +17,11 @@ export type AclCapability = (typeof ACL_CAPABILITIES)[number]; // 'create' | 're
export class PolicyStanza {
@tracked capabilities: Set<AclCapability> = new Set();
@tracked path = '';
@tracked path;
constructor({ path = '' } = {}) {
this.path = path;
}
get preview() {
return aclTemplate(this.path, Array.from(this.capabilities));

View file

@ -13,6 +13,13 @@
<:actions>
<Hds::Dropdown as |D|>
<D.ToggleButton @text="Manage" @color="secondary" data-test-dropdown="Manage" />
<CodeGenerator::Policy::Flyout @onClose={{D.close}}>
<:customTrigger as |openFlyout|>
<D.Interactive @icon="shield-check" {{on "click" openFlyout}} data-test-popup-menu="Generate policy">
Generate policy
</D.Interactive>
</:customTrigger>
</CodeGenerator::Policy::Flyout>
<D.Interactive
@icon="settings"
@route="configuration"

View file

@ -7,11 +7,16 @@
<:breadcrumbs>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</:breadcrumbs>
<:badges>
{{#if @path}}
<Hds::Copy::Button @isIconOnly={{true}} @text="Copy your secret path" @textToCopy={{@path}} data-test-copy-button />
{{/if}}
</:badges>
<:actions>
<CodeGenerator::Policy::Flyout />
</:actions>
</Page::Header>
<KvTabsToolbar>

View file

@ -7,11 +7,16 @@
<:breadcrumbs>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</:breadcrumbs>
<:badges>
{{#if @path}}
<Hds::Copy::Button @isIconOnly={{true}} @text="Copy your secret path" @textToCopy={{@path}} data-test-copy-button />
{{/if}}
</:badges>
<:actions>
<CodeGenerator::Policy::Flyout />
</:actions>
</Page::Header>
<KvTabsToolbar>

View file

@ -7,6 +7,10 @@
<:breadcrumbs>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</:breadcrumbs>
<:actions>
<CodeGenerator::Policy::Flyout />
</:actions>
</Page::Header>
<KvTabsToolbar>

View file

@ -7,11 +7,16 @@
<:breadcrumbs>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</:breadcrumbs>
<:badges>
{{#if @path}}
<Hds::Copy::Button @isIconOnly={{true}} @text="Copy your secret path" @textToCopy={{@path}} data-test-copy-button />
{{/if}}
</:badges>
<:actions>
<CodeGenerator::Policy::Flyout />
</:actions>
</Page::Header>
<KvTabsToolbar>

View file

@ -7,11 +7,16 @@
<:breadcrumbs>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</:breadcrumbs>
<:badges>
{{#if @path}}
<Hds::Copy::Button @isIconOnly={{true}} @text="Copy your secret path" @textToCopy={{@path}} data-test-copy-button />
{{/if}}
</:badges>
<:actions>
<CodeGenerator::Policy::Flyout />
</:actions>
</Page::Header>
<KvTabsToolbar>

View file

@ -7,11 +7,16 @@
<:breadcrumbs>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</:breadcrumbs>
<:badges>
{{#if @path}}
<Hds::Copy::Button @isIconOnly={{true}} @text="Copy your secret path" @textToCopy={{@path}} data-test-copy-button />
{{/if}}
</:badges>
<:actions>
<CodeGenerator::Policy::Flyout />
</:actions>
</Page::Header>
<KvTabsToolbar>

View file

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

View file

@ -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`<CodeGenerator::Policy::Flyout />`);
await render(hbs`<CodeGenerator::Policy::Flyout @onClose={{this.onClose}} />`);
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`<Hds::Dropdown as |D|>
<D.ToggleButton @text="Toolbox" data-test-dropdown="Toolbox" />
<CodeGenerator::Policy::Flyout>
<:customTrigger as |openFlyout|>
<D.Interactive @icon="shield-check" {{on "click" openFlyout}} data-test-button="Make me a policy!">
Make me a policy!
</D.Interactive>
</:customTrigger>
</CodeGenerator::Policy::Flyout>
<D.Interactive @icon="wand" data-test-button="Magic stuff">Magic stuff</D.Interactive>
</Hds::Dropdown>`);
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`<Hds::Dropdown as |D|>
<D.ToggleButton @text="Toolbox" data-test-dropdown="Toolbox" />
<CodeGenerator::Policy::Flyout @onClose={{D.close}} >
<:customTrigger as |openFlyout|>
<D.Interactive @icon="shield-check" {{on "click" openFlyout}} data-test-button="Make me a policy!">
Make me a policy!
</D.Interactive>
</:customTrigger>
</CodeGenerator::Policy::Flyout>
<D.Interactive @icon="wand" data-test-button="Magic stuff">Magic stuff</D.Interactive>
</Hds::Dropdown>`);
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`<Hds::Dropdown as |D|>
<D.ToggleButton @text="Toolbox" data-test-dropdown="Toolbox" />
<CodeGenerator::Policy::Flyout>
<:customTrigger as |openFlyout|>
<D.Interactive @icon="shield-check" {{on "click" openFlyout}} data-test-button="Make me a policy!">
Make me a policy!
</D.Interactive>
</:customTrigger>
</CodeGenerator::Policy::Flyout>
<D.Interactive @icon="wand" data-test-button="Magic stuff">Magic stuff</D.Interactive>
</Hds::Dropdown>`);
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);
});
});
});

View file

@ -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/*';

View file

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