UI: Add validations to visual policy editor (#14688) (#14697) (#14698)

* add validations to policy flyout

* add validations to policy form

* remove passing formatted policy back from policy/builder

* add changelog

* change label to "path"

Co-authored-by: claire b <68122737+hellobontempo@users.noreply.github.com>
This commit is contained in:
Vault Automation 2026-05-11 10:23:32 -06:00 committed by GitHub
parent 1227d13438
commit d8a2587e1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 400 additions and 61 deletions

3
changelog/_14688.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: add validations to the ACL visual policy editor to prevent it from saving policies with empty paths or capabilities.
```

View File

@ -7,7 +7,7 @@
<Hds::Form {{on "submit" (perform this.save)}} data-test-policy-form class="has-bottom-margin-xs" as |FORM|>
<FORM.Section @isFullWidth={{true}}>
<MessageError @errorMessage={{this.errorBanner}} />
<MessageError @errorMessage={{this.errorBanner}} @errorDetails={{this.errorDetails}} />
</FORM.Section>
{{#if @model.isNew}}
@ -88,6 +88,7 @@
@policyName={{@model.name}}
@onPolicyChange={{this.handlePolicyChange}}
@stanzas={{this.stanzas}}
@renderValidations={{if (this.validationError "stanzas") true false}}
data-test-field="visual editor"
/>
{{else}}

View File

@ -16,11 +16,12 @@ import {
PolicyTypes,
} from 'core/utils/code-generators/policy';
import errorMessage from 'vault/utils/error-message';
import { validate } from 'vault/utils/forms/validate';
import type FlashMessageService from 'ember-cli-flash/services/flash-messages';
import type { HTMLElementEvent } from 'vault/forms';
import type { PolicyData } from 'core/components/code-generator/policy/builder';
import type { FormField } from 'vault/vault/app-types';
import type { FormField, ValidationMap, Validations } from 'vault/vault/app-types';
/**
* @module PolicyForm
@ -67,13 +68,24 @@ export default class PolicyFormComponent extends Component<Args> {
@service declare readonly flashMessages: FlashMessageService;
editTypes = { [EditorTypes.VISUAL]: 'Visual editor', [EditorTypes.CODE]: 'Code editor' } as const;
validations: Validations = {
stanzas: [
{
validator: ({ stanzas }) =>
stanzas.length > 0 && stanzas.every((stanza: PolicyStanza) => stanza.isValid),
message: 'Invalid policy content.',
},
],
};
@tracked editType: EditorTypes = EditorTypes.VISUAL;
@tracked errorBanner = '';
@tracked errorDetails: string[] = [];
@tracked showFileUpload = false;
@tracked showSwitchEditorsModal = false;
@tracked showTemplateModal = false;
@tracked stanzas: PolicyStanza[] = [new PolicyStanza()];
@tracked validationErrors: ValidationMap | null = null;
constructor(owner: unknown, args: Args) {
super(owner, args);
@ -81,19 +93,29 @@ export default class PolicyFormComponent extends Component<Args> {
this.editType = this.args.model.policyType === PolicyTypes.ACL ? EditorTypes.VISUAL : EditorTypes.CODE;
}
// Template helpers
isActiveEditor = (type: string): boolean => type === this.editType;
validationError = (param: string) => {
const { isValid, errors } = this.validationErrors?.[param] ?? {};
return !isValid && errors ? errors.join(' ') : '';
};
get formattedStanzas() {
return formatStanzas(this.stanzas);
}
get hasPolicyDiff() {
const { policy } = this.args.model;
// Make sure policy has a value (if it's undefined, neither editor has been used)
// Return true if there is a difference between stanzas and policy arg
// which means the user has made changes using the code editor
return policy && formatStanzas(this.stanzas) !== policy;
return policy && this.formattedStanzas !== policy;
}
get snippetArgs() {
const policyName = this.args.model.name || '<policy name>';
const policy = formatStanzas(this.stanzas);
const policy = this.formattedStanzas;
return policySnippetArgs(policyName, policy);
}
@ -108,7 +130,7 @@ export default class PolicyFormComponent extends Component<Args> {
this.editType = EditorTypes.VISUAL;
this.showSwitchEditorsModal = false;
// Reset this.args.model.policy to match visual editor stanzas
this.setPolicy(formatStanzas(this.stanzas));
this.setPolicy(this.formattedStanzas);
}
@action
@ -118,9 +140,10 @@ export default class PolicyFormComponent extends Component<Args> {
}
@action
handlePolicyChange({ policy, stanzas }: PolicyData) {
this.setPolicy(policy);
handlePolicyChange({ stanzas }: PolicyData) {
// Update tracked stanzas first, then pass formatted policy back to model
this.stanzas = stanzas;
this.setPolicy(this.formattedStanzas);
}
@action
@ -146,6 +169,23 @@ export default class PolicyFormComponent extends Component<Args> {
@task
*save(event: HTMLElementEvent<HTMLFormElement>) {
event.preventDefault();
// Name is intentionally not validated here because the input has @isRequired=true
// which prevents the submit event all together when it is empty.
const { isValid, state } = validate({ stanzas: this.stanzas }, this.validations);
// Only enforce stanza validations for the Visual Editor
const shouldValidate = this.visualEditorSupported && this.editType === EditorTypes.VISUAL;
if (!isValid && shouldValidate) {
this.validationErrors = state;
this.errorDetails = Object.values(state).flatMap((s) => s.errors);
// Render general error message instead of exact count from validate() because
// stanzas (which are validated as a single input) can have up to 2 errors each.
const msg = this.errorDetails.length > 1 ? 'are errors' : 'is an error';
this.errorBanner = `There ${msg} with this form.`;
// Abort saving
return;
}
try {
const { name, policyType, isNew } = this.args.model;
yield this.args.model.save();

View File

@ -10,6 +10,7 @@
@onChange={{this.updateStanzas}}
@onDelete={{fn this.deleteStanza stanza}}
@stanza={{stanza}}
@renderValidations={{@renderValidations}}
class="has-bottom-margin-m"
/>
{{/each}}

View File

@ -5,11 +5,10 @@
import { action } from '@ember/object';
import Component from '@glimmer/component';
import { formatStanzas, PolicyStanza } from 'core/utils/code-generators/policy';
import { PolicyStanza } from 'core/utils/code-generators/policy';
import { assert } from '@ember/debug';
export interface PolicyData {
policy: string;
stanzas: PolicyStanza[];
}
interface Args {
@ -45,6 +44,6 @@ export default class CodeGeneratorPolicyBuilder extends Component<Args> {
@action
updateStanzas(stanzas?: PolicyStanza[]) {
const updated = stanzas ?? this.args.stanzas;
this.args.onPolicyChange({ policy: formatStanzas(updated), stanzas: updated });
this.args.onPolicyChange({ stanzas: updated });
}
}

View File

@ -28,7 +28,7 @@
<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}} />
<MessageError @errorMessage={{this.errorMessage}} @errorDetails={{this.errorDetails}} />
</FORM.Section>
{{/if}}
@ -65,6 +65,7 @@
@policyName={{this.policyName}}
@onPolicyChange={{this.handlePolicyChange}}
@stanzas={{this.stanzas}}
@renderValidations={{if (this.validationError "stanzas") true false}}
data-test-field="visual editor"
/>
</FORM.Section>

View File

@ -36,10 +36,17 @@ export default class CodeGeneratorPolicyFlyout extends Component<Args> {
validations: Validations = {
name: [{ type: 'presence', message: 'Name is required.' }],
stanzas: [
{
validator: ({ stanzas }) =>
stanzas.length > 0 && stanzas.every((stanza: PolicyStanza) => stanza.isValid),
message: 'Invalid policy content.',
},
],
};
@tracked errorDetails: string[] = [];
@tracked errorMessage = '';
@tracked policyContent = '';
@tracked policyName = '';
@tracked showFlyout = false;
@tracked stanzas: PolicyStanza[] = this.defaultStanzas;
@ -61,11 +68,14 @@ export default class CodeGeneratorPolicyFlyout extends Component<Args> {
}
@action
handlePolicyChange({ policy, stanzas }: PolicyData) {
this.policyContent = policy;
handlePolicyChange({ stanzas }: PolicyData) {
this.stanzas = stanzas;
}
get policyContent() {
return formatStanzas(this.stanzas);
}
get snippetArgs() {
const policyName = this.policyName || '<policy name>';
const policy = formatStanzas(this.stanzas);
@ -77,10 +87,15 @@ export default class CodeGeneratorPolicyFlyout extends Component<Args> {
event.preventDefault();
this.resetErrors();
const { isValid, state, invalidFormMessage } = validate({ name: this.policyName }, this.validations);
const { isValid, state } = validate({ name: this.policyName, stanzas: this.stanzas }, this.validations);
if (!isValid) {
this.validationErrors = state;
this.errorMessage = invalidFormMessage;
this.errorDetails = Object.values(state).flatMap((s) => s.errors);
// Render general error message instead of exact count from validate() because
// stanzas (which are validated as a single input) can have up to 2 errors each.
const msg = this.errorDetails.length > 1 ? 'are errors' : 'is an error';
this.errorMessage = `There ${msg} with this form.`;
return;
}
@ -125,11 +140,11 @@ export default class CodeGeneratorPolicyFlyout extends Component<Args> {
resetErrors() {
this.validationErrors = null;
this.errorMessage = '';
this.errorDetails = [];
}
resetFlyoutState() {
this.policyName = '';
this.policyContent = '';
this.stanzas = [new PolicyStanza()];
}

View File

@ -12,7 +12,7 @@
>
<Hds::Layout::Flex @justify="space-between" class="has-bottom-margin-xs">
<Hds::Text::Body @tag="p" @weight="semibold" @color="strong">
Rule
Path
</Hds::Text::Body>
<Hds::Form::Toggle::Field {{on "input" this.togglePreview}} data-test-toggle-input="preview" as |F|>
<F.Label data-test-form-field-label="preview">{{if this.showPreview "Hide" "Show"}} preview</F.Label>
@ -28,17 +28,23 @@
data-test-field="preview"
/>
{{else}}
<Hds::Layout::Flex @gap="8">
<Hds::Form::TextInput::Base
<Hds::Layout::Flex @align="start" @gap="8">
<Hds::Form::TextInput::Field
@type="text"
@value={{@stanza.path}}
@isInvalid={{and @renderValidations @stanza.invalidPath}}
aria-label="Resource path"
name="path-{{@index}}"
{{on "input" this.setPath}}
autocomplete="off"
placeholder="Enter a resource path"
data-test-input="path"
/>
as |F|
>
{{#if (and @renderValidations @stanza.invalidPath)}}
<F.Error data-test-validation-error="path-{{@index}}">{{@stanza.invalidPath}}</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
<Hds::Button
@icon="trash"
@isIconOnly={{true}}
@ -49,12 +55,13 @@
/>
</Hds::Layout::Flex>
<Hds::Form::Checkbox::Group @layout="horizontal" as |G|>
<Hds::Form::Checkbox::Group @layout="horizontal" @name="capabilities-{{@index}}" as |G|>
<G.Legend class="has-top-padding-m">Capabilities</G.Legend>
{{#each this.permissions as |capability|}}
<G.CheckboxField
checked={{this.hasCapability capability}}
@value={{capability}}
@isInvalid={{and @renderValidations @stanza.invalidCapabilities}}
{{on "input" this.setPermissions}}
data-test-checkbox={{capability}}
as |F|
@ -62,6 +69,9 @@
<F.Label data-test-form-field-label={{capability}}>{{capability}}</F.Label>
</G.CheckboxField>
{{/each}}
{{#if (and @renderValidations @stanza.invalidCapabilities)}}
<G.Error data-test-validation-error="capabilities-{{@index}}">{{@stanza.invalidCapabilities}}</G.Error>
{{/if}}
</Hds::Form::Checkbox::Group>
{{/if}}

View File

@ -39,7 +39,19 @@
as |A|
>
<A.Title>Error</A.Title>
<A.Description data-test-message-error-description>{{error}}</A.Description>
<A.Description data-test-message-error-description>
{{error}}
{{#if @errorDetails}}
<ul class="bullet">
{{#each @errorDetails as |detail|}}
<li>
{{detail}}
</li>
{{/each}}
</ul>
{{/if}}
</A.Description>
</Hds::Alert>
{{/each}}
{{/if}}

View File

@ -16,6 +16,7 @@ import Component from '@glimmer/component';
* @param {object} [model=null] - An Ember data model that will be used to bind error states to the internal `errors` property.
* @param {array} [errors=null] - An array of error strings to show.
* @param {string} [errorMessage=null] - An Error string to display.
* @param {array} [errorDetails=null] - Renders a list of errors when error is not from the API. Helpful for rendering a list of client-side validation errors.
*/
export default class MessageError extends Component {

View File

@ -26,6 +26,23 @@ export class PolicyStanza {
get preview() {
return aclTemplate(this.path, Array.from(this.capabilities));
}
get isValid() {
// Stanza must have a non-empty path and capabilities selected to be valid
return !this.invalidCapabilities && !this.invalidPath;
}
// These getters return an error message when invalid and an empty string when valid.
// Negative naming is a little counterintuitive, but simplifies template logic
// so the message only renders when the value is truthy.
get invalidCapabilities() {
return this.capabilities.size > 0 ? '' : 'Rule must have at least one capability.';
}
get invalidPath() {
const isValid = typeof this.path === 'string' && this.path.trim().length > 0;
return isValid ? '' : 'Path cannot be empty.';
}
}
export const formatStanzas = (stanzas: PolicyStanza[]) => stanzas.map((s) => s.preview).join('\n');

View File

@ -110,11 +110,12 @@ module('Acceptance | policies/acl', function (hooks) {
await click(SELECT.createPolicy);
await fillIn(GENERAL.inputByAttr('name'), policyName);
// Select code editor first to bypass visual policy editor validations so we get an API error
await click(GENERAL.radioByAttr('code'));
await click(GENERAL.submitButton);
assert
.dom(GENERAL.messageError)
.hasText(`Error 'policy' parameter not supplied or empty`, 'renders error message on save');
await click(GENERAL.radioByAttr('code'));
await waitFor('.cm-editor');
const editor = codemirror();
setCodeEditorValue(editor, policyString);

View File

@ -150,8 +150,8 @@ export const GENERAL = {
inlineError: '[data-test-inline-error-message]',
messageError: '[data-test-message-error]',
messageDescription: '[data-test-message-error-description]',
validationErrorByAttr: (attr: string) => `[data-test-validation-error=${attr}]`,
validationWarningByAttr: (attr: string) => `[data-test-validation-warning=${attr}]`,
validationErrorByAttr: (attr: string) => `[data-test-validation-error="${attr}"]`,
validationWarningByAttr: (attr: string) => `[data-test-validation-warning="${attr}"]`,
pageError: {
error: '[data-test-page-error]',

View File

@ -8,19 +8,21 @@ import { setupRenderingTest } from 'vault/tests/helpers';
import { render, click, fillIn, setupOnerror } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { ACL_CAPABILITIES, PolicyStanza } from 'core/utils/code-generators/policy';
import { ACL_CAPABILITIES, formatStanzas, PolicyStanza } from 'core/utils/code-generators/policy';
module('Integration | Component | code-generator/policy/builder', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.onPolicyChange = ({ policy, stanzas }) => {
this.set('policyCallback', policy);
this.onPolicyChange = ({ stanzas }) => {
// Mimics consumers of this component which use formatStanzas as needed
this.set('policyCallback', formatStanzas(stanzas));
this.set('stanzas', stanzas);
};
this.policyCallback = '';
this.policyName = undefined;
this.renderValidations = false;
this.stanzas = [new PolicyStanza()];
this.renderComponent = () => {
@ -28,6 +30,7 @@ module('Integration | Component | code-generator/policy/builder', function (hook
<CodeGenerator::Policy::Builder
@onPolicyChange={{this.onPolicyChange}}
@policyName={{this.policyName}}
@renderValidations={{this.renderValidations}}
@stanzas={{this.stanzas}}
/>`);
};
@ -230,4 +233,13 @@ path "" {
}`;
this.assertPolicyUpdate(assert, expectedPolicy, 'when rules do have an empty path');
});
test('it passes @renderValidations through to CodeGenerator::Policy::Stanza', async function (assert) {
this.renderValidations = true;
await this.renderComponent();
assert.dom(GENERAL.validationErrorByAttr('path-0')).hasText('Path cannot be empty.');
assert
.dom(GENERAL.validationErrorByAttr('capabilities-0'))
.hasText('Rule must have at least one capability.');
});
});

View File

@ -208,15 +208,6 @@ EOT`;
assert.dom(GENERAL.inputByAttr('name')).hasValue('mypolicy', 'name is converted to lowercase');
});
test('it does not submit default stanza templates as policy payload', async function (assert) {
assert.expect(3);
const expectedPolicy = '';
this.assertSaveRequest(assert, expectedPolicy, 'policy payload is empty when visual editor is untouched');
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('name'), 'test-policy');
await click(GENERAL.submitButton);
});
test('it saves a policy', async function (assert) {
assert.expect(7);
const flashSuccessSpy = Sinon.spy(this.owner.lookup('service:flash-messages'), 'success');
@ -246,7 +237,7 @@ EOT`;
});
test('it resets after saving a policy', async function (assert) {
assert.expect(11);
assert.expect(8);
const expectedPolicy = `path "secret/data/*" {\n capabilities = ["read"]\n}`;
this.assertSaveRequest(assert, expectedPolicy);
await this.renderComponent();
@ -277,20 +268,18 @@ EOT
}
EOT`;
assert.dom(GENERAL.fieldByAttr('cli')).hasText(expectedCli);
// Fill in name and save again to make sure policyContent is reset
this.assertSaveRequest(assert, '', 'policy content is empty after a successful save');
await fillIn(GENERAL.inputByAttr('name'), 'test-policy');
await click(GENERAL.submitButton);
});
test('it displays error message when save fails', async function (assert) {
test('it displays API error message when save fails', async function (assert) {
this.server.post('/sys/policies/acl/:name', () => {
return overrideResponse(400, { errors: ["'policy' parameter not supplied or empty"] });
return overrideResponse(400, { errors: ['something went very wrong'] });
});
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('name'), 'empty-policy');
await fillIn(GENERAL.inputByAttr('name'), 'failed-policy');
await fillIn(GENERAL.inputByAttr('path'), 'secret/data/*');
await click(GENERAL.checkboxByAttr('read'));
await click(GENERAL.submitButton);
assert.dom(GENERAL.messageError).exists().hasText("Error 'policy' parameter not supplied or empty");
assert.dom(GENERAL.messageError).exists().hasText('Error something went very wrong');
assert.dom(GENERAL.flyout).exists('flyout remains open after error');
});
@ -326,22 +315,85 @@ EOT`;
await click(GENERAL.submitButton);
});
test('it renders validation errors', async function (assert) {
test('it renders validation errors for invalid policy content', async function (assert) {
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('name'), 'test-policy');
// Submit without filling in path or capabilities (missing path AND no capabilities)
await click(GENERAL.submitButton);
assert.dom(GENERAL.messageError).exists().hasText('Error There is an error with this form.');
assert
.dom(GENERAL.messageError)
.exists()
.hasText('Error There is an error with this form. Invalid policy content.');
assert.dom(SELECTORS.pathByContainer(0)).hasClass('hds-form-text-input--is-invalid');
assert.dom(GENERAL.validationErrorByAttr('path-0')).exists().hasText('Path cannot be empty.');
assert
.dom(GENERAL.validationErrorByAttr('capabilities-0'))
.exists()
.hasText('Rule must have at least one capability.');
});
test('it renders validation errors for missing path', async function (assert) {
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('name'), 'test-policy');
await fillIn(GENERAL.inputByAttr('path'), 'secret/*');
await click(GENERAL.checkboxByAttr('read'));
// Add a second rule and just select capabilities (leave path empty)
await click(GENERAL.button('Add rule'));
await click(SELECTORS.checkboxByContainer(1, 'update'));
await click(GENERAL.submitButton);
assert
.dom(GENERAL.messageError)
.exists()
.hasText('Error There is an error with this form. Invalid policy content.');
assert.dom(SELECTORS.pathByContainer(1)).hasClass('hds-form-text-input--is-invalid');
assert.dom(GENERAL.validationErrorByAttr('path-1')).hasText('Path cannot be empty.');
assert.dom('[data-test-validation-error]').exists({ count: 1 }, 'only one validation error renders');
});
test('it renders validation errors for missing capabilities', async function (assert) {
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('name'), 'test-policy');
await fillIn(GENERAL.inputByAttr('path'), 'secret/*');
await click(GENERAL.checkboxByAttr('read'));
// Add a second rule and just fill in path (no capabilities selected)
await click(GENERAL.button('Add rule'));
await fillIn(SELECTORS.pathByContainer(1), 'new/path/*');
await click(GENERAL.submitButton);
assert
.dom(GENERAL.messageError)
.exists()
.hasText('Error There is an error with this form. Invalid policy content.');
assert
.dom(GENERAL.validationErrorByAttr('capabilities-1'))
.exists()
.hasText('Rule must have at least one capability.');
assert.dom('[data-test-validation-error]').exists({ count: 1 }, 'only one validation error renders');
});
test('it renders validation error for empty policy name', async function (assert) {
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('path'), 'secret/*');
await click(GENERAL.checkboxByAttr('read'));
await click(GENERAL.submitButton);
assert
.dom(GENERAL.messageError)
.exists()
.hasText('Error There is an error with this form. Name is required.');
assert.dom(GENERAL.inputByAttr('name')).hasClass('hds-form-text-input--is-invalid');
assert.dom(GENERAL.validationErrorByAttr('name')).hasText('Name is required.');
});
test('it resets errors after saving', async function (assert) {
test('it resets validation errors after saving', async function (assert) {
const expectedPolicy = `path "secret/*" {\n capabilities = ["read"]\n}`;
this.assertSaveRequest(assert, expectedPolicy);
await this.renderComponent();
// First attempt without name
// First attempt without name and policy content
await click(GENERAL.submitButton);
assert.dom(GENERAL.messageError).exists().hasText('Error There is an error with this form.');
assert
.dom(GENERAL.messageError)
.exists()
.hasText('Error There are errors with this form. Name is required. Invalid policy content.');
assert.dom(GENERAL.validationErrorByAttr('name')).exists('validation error shows');
// Second attempt with name
@ -356,11 +408,14 @@ EOT`;
assert.dom(GENERAL.validationErrorByAttr('name')).doesNotExist('validation error is cleared');
});
test('it resets errors if flyout is closed and policy is NOT saved', async function (assert) {
test('it resets validation errors if flyout is closed and policy is NOT saved', async function (assert) {
await this.renderComponent();
// Attempt to save
await click(GENERAL.submitButton);
assert.dom(GENERAL.messageError).exists().hasText('Error There is an error with this form.');
assert
.dom(GENERAL.messageError)
.exists()
.hasText('Error There are errors with this form. Name is required. Invalid policy content.');
assert.dom(GENERAL.validationErrorByAttr('name')).exists('validation error shows');
// Cancel and close flyout
await click(GENERAL.cancelButton);
@ -493,14 +548,24 @@ EOT`;
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);
test('prepopulated paths trigger validation errors', async function (assert) {
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');
// Only fill in name to make sure capabilities validation triggers
await fillIn(GENERAL.inputByAttr('name'), 'test-policy');
await click(GENERAL.submitButton);
assert
.dom(GENERAL.messageError)
.exists()
.hasText('Error There is an error with this form. Invalid policy content.');
assert
.dom(GENERAL.validationErrorByAttr('capabilities-0'))
.exists()
.hasText('Rule must have at least one capability.');
assert
.dom(GENERAL.validationErrorByAttr('capabilities-1'))
.exists()
.hasText('Rule must have at least one capability.');
});
});
});

View File

@ -26,6 +26,7 @@ module('Integration | Component | code-generator/policy/stanza', function (hooks
@onChange={{this.onChange}}
@onDelete={{this.onDelete}}
@stanza={{this.stanza}}
@renderValidations={{this.renderValidations}}
/>`);
};
});
@ -168,4 +169,61 @@ module('Integration | Component | code-generator/policy/stanza', function (hooks
await click(GENERAL.button('Delete'));
assert.true(this.onDelete.calledOnce, 'onDelete is called');
});
test('it does not render validations when @renderValidations is undefined', async function (assert) {
await this.renderComponent();
// Make sure path and capabilities are empty (which would normally render validations)
assert.dom(GENERAL.inputByAttr('path')).hasNoValue();
ACL_CAPABILITIES.forEach((capability) => {
assert.dom(GENERAL.fieldLabel(capability)).hasText(capability);
assert.dom(GENERAL.checkboxByAttr(capability)).isNotChecked();
});
assert.dom('[data-test-validation-error]').doesNotExist();
});
module('validations', function (hooks) {
hooks.beforeEach(function () {
this.renderValidations = true;
});
test('it renders validations when path is empty', async function (assert) {
await this.renderComponent();
// Click capability to just assert path
await click(GENERAL.checkboxByAttr('update'));
assert.dom(GENERAL.validationErrorByAttr('path-0')).exists().hasText('Path cannot be empty.');
assert.dom('[data-test-validation-error]').exists({ count: 1 });
});
test('it renders validations when no capabilities are selected', async function (assert) {
await this.renderComponent();
// Fill in path to only assert capabilities
await fillIn(GENERAL.inputByAttr('path'), 'my/super/secret/*');
assert
.dom(GENERAL.validationErrorByAttr('capabilities-0'))
.exists()
.hasText('Rule must have at least one capability.');
assert.dom('[data-test-validation-error]').exists({ count: 1 });
});
test('it renders validations for neither path or capabilities', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.validationErrorByAttr('path-0')).exists();
assert.dom(GENERAL.validationErrorByAttr('capabilities-0')).exists();
assert.dom('[data-test-validation-error]').exists({ count: 2 });
});
test('it removes validation when path is valid', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.validationErrorByAttr('path-0')).exists();
await fillIn(GENERAL.inputByAttr('path'), 'secret/data/*');
assert.dom(GENERAL.validationErrorByAttr('path-0')).doesNotExist();
});
test('it removes validation when at least one capability is selected', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.validationErrorByAttr('capabilities-0')).exists();
await click(GENERAL.checkboxByAttr('read'));
assert.dom(GENERAL.validationErrorByAttr('capabilities-0')).doesNotExist();
});
});
});

View File

@ -110,4 +110,26 @@ module('Integration | Component | message-error', function (hooks) {
await click(GENERAL.icon('x'));
assert.true(this.onDismiss.calledOnce, '@onDismiss is called once');
});
test('it renders @errorDetails as a list under the error message', async function (assert) {
this.errorMessage = 'There is an error with this form.';
await render(hbs`
<MessageError
@errorMessage={{this.errorMessage}}
@errorDetails={{array "Path cannot be empty." "Rule must have at least one capability."}}
/>`);
assert.dom(GENERAL.messageError).exists({ count: 1 });
assert.dom(GENERAL.messageDescription).hasTextContaining('There is an error with this form.');
assert.dom(`${GENERAL.messageDescription} li`).exists({ count: 2 });
assert.dom(`${GENERAL.messageDescription} li:first-child`).hasText('Path cannot be empty.');
assert
.dom(`${GENERAL.messageDescription} li:last-child`)
.hasText('Rule must have at least one capability.');
});
test('it does not render error details list when @errorDetails is not provided', async function (assert) {
this.errorMessage = 'Something went wrong';
await this.renderComponent();
assert.dom(`${GENERAL.messageDescription} ul`).doesNotExist();
});
});

View File

@ -151,10 +151,12 @@ module('Integration | Component | policy-form', function (hooks) {
);
});
test('it shows the error message on form when save fails', async function (assert) {
test('it shows the error message on form when save returns API error', async function (assert) {
this.model.name = 'bad-policy';
this.model.policy = 'some policy content';
await this.renderComponent();
// Change editors so we don't trigger visual editor validations
await click(GENERAL.radioByAttr('code'));
await click(GENERAL.submitButton);
assert.true(this.onSave.notCalled, 'onSave is not called yet');
assert.dom(GENERAL.messageError).includesText('An error occurred');
@ -231,7 +233,7 @@ module('Integration | Component | policy-form', function (hooks) {
assert.dom(GENERAL.codemirror).doesNotExist('JSON editor does not render by default');
assert
.dom(GENERAL.fieldByAttr('visual editor'))
.hasTextContaining('Rule Show preview')
.hasTextContaining('Path Show preview')
.exists('it renders visual policy editor by default');
// Select Code editor
await click(GENERAL.radioByAttr('code'));
@ -246,7 +248,7 @@ module('Integration | Component | policy-form', function (hooks) {
assert.dom(GENERAL.codemirror).doesNotExist();
assert
.dom(GENERAL.fieldByAttr('visual editor'))
.hasTextContaining('Rule Show preview')
.hasTextContaining('Path Show preview')
.exists('Visual editor renders after selecting radio');
});
@ -276,6 +278,33 @@ path "second/path" {
assert.strictEqual(actual.policy, expectedPolicy, 'save is called with expected policy');
});
test('it shows validation errors for invalid policy stanzas', async function (assert) {
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('name'), 'test-policy');
await click(GENERAL.submitButton);
assert.true(this.onSave.notCalled, 'onSave is not called');
assert
.dom(GENERAL.messageError)
.exists()
.hasText('Error There is an error with this form. Invalid policy content.');
assert.dom(GENERAL.validationErrorByAttr('path-0')).hasText('Path cannot be empty.');
assert
.dom(GENERAL.validationErrorByAttr('capabilities-0'))
.hasText('Rule must have at least one capability.');
});
test('it still saves from the code editor when visual stanzas are invalid', async function (assert) {
await this.renderComponent();
await fillIn(GENERAL.inputByAttr('name'), 'test-policy');
await click(GENERAL.radioByAttr('code'));
await setEditorValue(this.policy);
await click(GENERAL.submitButton);
assert.true(this.onSave.calledOnceWith(this.model), 'onSave is called with model');
assert.dom(GENERAL.messageError).doesNotExist('validation banner does not render in code editor');
});
// Automation snippets are only supported for "ACL" policy types at this time
test('it renders empty snippets by default', async function (assert) {
await this.renderComponent();

View File

@ -142,4 +142,56 @@ module('Integration | Util | code-generators/policy', function (hooks) {
assert.true(secondPreview.includes('"read", "list"'), 'new preview includes both capabilities');
assert.strictEqual(secondPreview, expected, 'new preview reflects updates');
});
test('PolicyStanza: invalidPath returns error message when path is empty', function (assert) {
const stanza = new PolicyStanza();
assert.strictEqual(stanza.invalidPath, 'Path cannot be empty.', 'returns error for empty path');
});
test('PolicyStanza: invalidPath returns error message when path is only whitespace', function (assert) {
const stanza = new PolicyStanza({ path: ' ' });
assert.strictEqual(stanza.invalidPath, 'Path cannot be empty.', 'returns error for whitespace path');
});
test('PolicyStanza: invalidPath returns empty string when path is valid', function (assert) {
const stanza = new PolicyStanza({ path: 'secret/data/*' });
assert.strictEqual(stanza.invalidPath, '', 'returns empty string for valid path');
});
test('PolicyStanza: invalidCapabilities returns error message when no capabilities are selected', function (assert) {
const stanza = new PolicyStanza();
assert.strictEqual(
stanza.invalidCapabilities,
'Rule must have at least one capability.',
'returns error when no capabilities selected'
);
});
test('PolicyStanza: invalidCapabilities returns empty string when at least one capability is selected', function (assert) {
const stanza = new PolicyStanza();
stanza.capabilities.add('read');
assert.strictEqual(stanza.invalidCapabilities, '', 'returns empty string when capability is selected');
});
test('PolicyStanza: isValid returns false when path and capabilities are both empty', function (assert) {
const stanza = new PolicyStanza();
assert.false(stanza.isValid, 'invalid when path and capabilities are empty');
});
test('PolicyStanza: isValid returns false when path is empty but capabilities are set', function (assert) {
const stanza = new PolicyStanza();
stanza.capabilities.add('read');
assert.false(stanza.isValid, 'invalid when path is empty');
});
test('PolicyStanza: isValid returns false when path is set but capabilities are empty', function (assert) {
const stanza = new PolicyStanza({ path: 'secret/data/*' });
assert.false(stanza.isValid, 'invalid when no capabilities selected');
});
test('PolicyStanza: isValid returns true when path and capabilities are both set', function (assert) {
const stanza = new PolicyStanza({ path: 'secret/data/*' });
stanza.capabilities.add('read');
assert.true(stanza.isValid, 'valid when path and capabilities are set');
});
});