mirror of
https://github.com/hashicorp/vault.git
synced 2026-02-03 20:40:45 -05:00
* 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:
parent
062537e1fe
commit
7a29044ea4
16 changed files with 447 additions and 103 deletions
3
changelog/_11798.txt
Normal file
3
changelog/_11798.txt
Normal 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.
|
||||
```
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@
|
|||
<:breadcrumbs>
|
||||
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
|
||||
</:breadcrumbs>
|
||||
|
||||
<:actions>
|
||||
<CodeGenerator::Policy::Flyout />
|
||||
</:actions>
|
||||
</Page::Header>
|
||||
|
||||
<KvTabsToolbar>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/*';
|
||||
|
|
|
|||
|
|
@ -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] };
|
||||
|
|
|
|||
Loading…
Reference in a new issue