mirror of
https://github.com/hashicorp/vault.git
synced 2026-02-03 20:40:45 -05:00
* wip policy stanza builder * Implement add and delete new stanza functionality * refactor to use Set() * make copy updates, add callback functionality to pass policy to parent * move policy formatter to util, add test coverage * =separate acl-policy component into two smaller components, add automation snippets * reorganize utils, add test coverage * finish rename * reduce scope of builder * fix spacing * add a ns test, remove unused spacing var * rename arg Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
This commit is contained in:
parent
c34e25fb76
commit
63bbbd163b
20 changed files with 1204 additions and 9 deletions
|
|
@ -38,6 +38,11 @@
|
|||
padding-right: size_variables.$spacing-12;
|
||||
}
|
||||
|
||||
.side-padding-24 {
|
||||
padding-left: size_variables.$spacing-24;
|
||||
padding-right: size_variables.$spacing-24;
|
||||
}
|
||||
|
||||
.has-padding-8 {
|
||||
padding: size_variables.$spacing-8;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
{{!
|
||||
Copyright IBM Corp. 2016, 2025
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
}}
|
||||
|
||||
{{#each this.stanzas as |stanza idx|}}
|
||||
<CodeGenerator::Policy::Stanza
|
||||
@index={{idx}}
|
||||
@onChange={{this.handleChange}}
|
||||
@onDelete={{fn this.deleteStanza stanza}}
|
||||
@stanza={{stanza}}
|
||||
class="has-top-bottom-margin-12"
|
||||
/>
|
||||
{{/each}}
|
||||
|
||||
<div class="has-top-padding-m">
|
||||
<Hds::Button @icon="plus" @text="Add rule" @color="secondary" {{on "click" this.addStanza}} data-test-button="Add rule" />
|
||||
</div>
|
||||
|
||||
<Hds::Separator />
|
||||
|
||||
<Hds::Card::Container @hasBorder={{true}} class="has-top-padding-m has-bottom-padding-m side-padding-24">
|
||||
<Hds::Reveal @text="Automation snippets" class="is-fullwidth" data-test-reveal="Automation snippets">
|
||||
<Hds::Layout::Flex @gap="24" class="has-top-bottom-margin-12">
|
||||
{{#each-in this.snippetTypes as |value label|}}
|
||||
<Hds::Form::Radio::Field
|
||||
name="snippetType"
|
||||
@value={{value}}
|
||||
checked={{eq this.snippetType value}}
|
||||
{{on "change" this.handleRadio}}
|
||||
data-test-input={{value}}
|
||||
as |F|
|
||||
>
|
||||
<F.Label>{{label}}</F.Label>
|
||||
</Hds::Form::Radio::Field>
|
||||
{{/each-in}}
|
||||
</Hds::Layout::Flex>
|
||||
|
||||
<Hds::CodeBlock
|
||||
@language="hcl"
|
||||
@value={{this.snippet}}
|
||||
@hasLineNumbers={{false}}
|
||||
@hasCopyButton={{true}}
|
||||
data-test-field="snippets"
|
||||
/>
|
||||
|
||||
</Hds::Reveal>
|
||||
</Hds::Card::Container>
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { action } from '@ember/object';
|
||||
import { service } from '@ember/service';
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { formatCli, writePolicy } from 'core/utils/code-generators/cli';
|
||||
import { formatEot } from 'core/utils/code-generators/formatters';
|
||||
import { PolicyStanza } from 'core/utils/code-generators/policy';
|
||||
import { terraformTemplate } from 'core/utils/code-generators/terraform';
|
||||
|
||||
import type { HTMLElementEvent } from 'vault/forms';
|
||||
import type NamespaceService from 'vault/services/namespace';
|
||||
|
||||
interface Args {
|
||||
// Callback to pass the generated policy back to the parent
|
||||
onPolicyChange: ((policy: string) => void) | undefined;
|
||||
policyName?: 'string';
|
||||
}
|
||||
|
||||
export default class CodeGeneratorPolicyBuilder extends Component<Args> {
|
||||
@service declare readonly namespace: NamespaceService;
|
||||
|
||||
snippetTypes = { terraform: 'Terraform Vault Provider', cli: 'CLI' };
|
||||
|
||||
@tracked snippetType = 'terraform';
|
||||
@tracked stanzas = [new PolicyStanza()];
|
||||
|
||||
get formattedPolicy() {
|
||||
return this.stanzas.map((s) => s.preview).join('\n');
|
||||
}
|
||||
|
||||
get snippet() {
|
||||
const policyName = this.args.policyName || '<policy name>';
|
||||
const policy = formatEot(this.formattedPolicy);
|
||||
const options = {
|
||||
// only add namespace if we're not in root (when namespace is '')
|
||||
...(!this.namespace.inRootNamespace ? { namespace: `"${this.namespace.path}"` } : null),
|
||||
name: `"${policyName}"`,
|
||||
policy,
|
||||
};
|
||||
switch (this.snippetType) {
|
||||
case 'terraform':
|
||||
return terraformTemplate({ resource: 'vault_policy', options });
|
||||
case 'cli':
|
||||
return formatCli({ command: writePolicy(policyName), content: `- ${policy}` });
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
addStanza() {
|
||||
const stanzas = [...this.stanzas, new PolicyStanza()];
|
||||
this.updateStanzas(stanzas);
|
||||
}
|
||||
|
||||
@action
|
||||
deleteStanza(stanza: PolicyStanza) {
|
||||
const remaining = [...this.stanzas.filter((s) => s !== stanza)];
|
||||
// Create an empty template if the only stanza was deleted
|
||||
const stanzas = remaining.length ? [...remaining] : [new PolicyStanza()];
|
||||
this.updateStanzas(stanzas);
|
||||
}
|
||||
|
||||
@action
|
||||
handleChange() {
|
||||
if (this.args.onPolicyChange) {
|
||||
this.args.onPolicyChange(this.formattedPolicy);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleRadio(event: HTMLElementEvent<HTMLInputElement>) {
|
||||
const { value } = event.target;
|
||||
this.snippetType = value;
|
||||
}
|
||||
|
||||
updateStanzas(stanzas: PolicyStanza[]) {
|
||||
// Trigger an update by reassigning tracked variable
|
||||
this.stanzas = stanzas;
|
||||
this.handleChange();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
{{!
|
||||
Copyright IBM Corp. 2016, 2025
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
}}
|
||||
|
||||
<Hds::Card::Container
|
||||
class="has-top-padding-m has-bottom-padding-m side-padding-24"
|
||||
@hasBorder={{true}}
|
||||
@level="mid"
|
||||
...attributes
|
||||
data-test-card-container={{@index}}
|
||||
>
|
||||
<Hds::Layout::Flex @justify="space-between" class="has-bottom-margin-xs">
|
||||
<Hds::Text::Body @tag="p" @weight="semibold" @color="strong">
|
||||
Rule
|
||||
</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>
|
||||
</Hds::Form::Toggle::Field>
|
||||
</Hds::Layout::Flex>
|
||||
|
||||
{{#if this.showPreview}}
|
||||
<Hds::CodeBlock
|
||||
@language="hcl"
|
||||
@value={{@stanza.preview}}
|
||||
@hasLineNumbers={{false}}
|
||||
@hasCopyButton={{true}}
|
||||
data-test-field="preview"
|
||||
/>
|
||||
{{else}}
|
||||
<Hds::Layout::Flex @gap="8">
|
||||
<Hds::Form::TextInput::Base
|
||||
@type="text"
|
||||
@value={{@stanza.path}}
|
||||
aria-label="Resource path"
|
||||
name="path-{{@index}}"
|
||||
{{on "input" this.setPath}}
|
||||
autocomplete="off"
|
||||
placeholder="Enter a resource path"
|
||||
data-test-input="path"
|
||||
/>
|
||||
<Hds::Button
|
||||
@icon="trash"
|
||||
@isIconOnly={{true}}
|
||||
@text="Delete"
|
||||
@color="critical"
|
||||
{{on "click" @onDelete}}
|
||||
data-test-button="Delete"
|
||||
/>
|
||||
</Hds::Layout::Flex>
|
||||
|
||||
<Hds::Form::Checkbox::Group @layout="horizontal" 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}}
|
||||
{{on "input" this.setPermissions}}
|
||||
data-test-checkbox={{capability}}
|
||||
as |F|
|
||||
>
|
||||
<F.Label data-test-form-field-label={{capability}}>{{capability}}</F.Label>
|
||||
</G.CheckboxField>
|
||||
{{/each}}
|
||||
</Hds::Form::Checkbox::Group>
|
||||
{{/if}}
|
||||
|
||||
</Hds::Card::Container>
|
||||
47
ui/lib/core/addon/components/code-generator/policy/stanza.ts
Normal file
47
ui/lib/core/addon/components/code-generator/policy/stanza.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { ACL_CAPABILITIES, isAclCapability } from 'core/utils/code-generators/policy';
|
||||
|
||||
import type { HTMLElementEvent } from 'vault/forms';
|
||||
import type { AclCapability, PolicyStanza } from 'core/utils/code-generators/policy';
|
||||
|
||||
interface Args {
|
||||
stanza: PolicyStanza;
|
||||
onChange: (() => void) | undefined;
|
||||
}
|
||||
export default class CodeGeneratorPolicyStanza extends Component<Args> {
|
||||
@tracked showPreview = false;
|
||||
|
||||
readonly permissions = ACL_CAPABILITIES;
|
||||
|
||||
hasCapability = (c: AclCapability) => this.args.stanza.capabilities.has(c);
|
||||
|
||||
@action
|
||||
togglePreview() {
|
||||
this.showPreview = !this.showPreview;
|
||||
}
|
||||
|
||||
@action
|
||||
setPath(event: HTMLElementEvent<HTMLInputElement>) {
|
||||
this.args.stanza.path = event.target.value;
|
||||
this.args.onChange?.();
|
||||
}
|
||||
|
||||
@action
|
||||
setPermissions(event: HTMLElementEvent<HTMLInputElement>) {
|
||||
const { value, checked } = event.target;
|
||||
if (isAclCapability(value)) {
|
||||
const capabilities = new Set(this.args.stanza.capabilities);
|
||||
checked ? capabilities.add(value) : capabilities.delete(value);
|
||||
// Update stanza with list of capabilities
|
||||
this.args.stanza.capabilities = capabilities;
|
||||
this.args.onChange?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
15
ui/lib/core/addon/utils/code-generators/cli.ts
Normal file
15
ui/lib/core/addon/utils/code-generators/cli.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export interface FormatCliArgs {
|
||||
command: string; // The CLI command (e.g., "policy write my-policy")
|
||||
content: string; // The content/body to pass to the command
|
||||
}
|
||||
|
||||
export const formatCli = ({ command, content }: FormatCliArgs) => {
|
||||
return `vault ${command} ${content}`.trim();
|
||||
};
|
||||
|
||||
export const writePolicy = (name: string) => `policy write ${name}`;
|
||||
15
ui/lib/core/addon/utils/code-generators/formatters.ts
Normal file
15
ui/lib/core/addon/utils/code-generators/formatters.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wraps content in a heredoc (EOT - End of Text) block.
|
||||
* @param content - The content to wrap in the heredoc block
|
||||
* @returns A formatted heredoc string with EOT delimiters
|
||||
*/
|
||||
export const formatEot = (content: string) => {
|
||||
return `<<EOT
|
||||
${content}
|
||||
EOT`;
|
||||
};
|
||||
43
ui/lib/core/addon/utils/code-generators/policy.ts
Normal file
43
ui/lib/core/addon/utils/code-generators/policy.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export const ACL_CAPABILITIES = ['create', 'read', 'update', 'delete', 'list', 'patch', 'sudo'] as const;
|
||||
export type AclCapability = (typeof ACL_CAPABILITIES)[number]; // 'create' | 'read' | 'update' | 'delete' | 'list' | 'patch' | 'sudo'
|
||||
|
||||
export class PolicyStanza {
|
||||
@tracked capabilities: Set<AclCapability> = new Set();
|
||||
@tracked path = '';
|
||||
|
||||
get preview() {
|
||||
return aclTemplate(this.path, Array.from(this.capabilities));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an ACL policy stanza in HCL
|
||||
* @param path - The Vault API path the policy applies to (e.g., "secret/data/*")
|
||||
* @param capabilities - Array of capabilities (e.g., '"read", "list"')
|
||||
* @returns A formatted HCL policy string
|
||||
*/
|
||||
export const aclTemplate = (path: string, capabilities: AclCapability[]) => {
|
||||
const formatted = formatCapabilities(capabilities);
|
||||
// Indentions below are intentional so policy renders prettily in code editor
|
||||
return `path "${path}" {
|
||||
capabilities = [${formatted}]
|
||||
}`;
|
||||
};
|
||||
|
||||
// returns a string with each capability wrapped in double quotes => ["create", "read"]
|
||||
export const formatCapabilities = (capabilities: AclCapability[]) => {
|
||||
// Filter from ACL_CAPABILITIES to list capabilities in consistent order
|
||||
const allowed = ACL_CAPABILITIES.filter((p) => capabilities.includes(p));
|
||||
return allowed.length ? allowed.map((c) => `"${c}"`).join(', ') : '';
|
||||
};
|
||||
|
||||
// Type Guards
|
||||
export const isAclCapability = (value: string): value is AclCapability =>
|
||||
ACL_CAPABILITIES.includes(value as AclCapability);
|
||||
34
ui/lib/core/addon/utils/code-generators/terraform.ts
Normal file
34
ui/lib/core/addon/utils/code-generators/terraform.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export interface TerraformTemplateArgs {
|
||||
resource: string; // The Terraform resource type (e.g., "vault_auth_backend", "vault_mount")
|
||||
localId?: string; // The local identifier/label for this resource instance. Used in Terraform commands like `terraform import vault_auth_backend.example github` (<- is "example" here)
|
||||
options: TerraformOptions; // Key/value pairs that build the terraform configuration
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a Terraform resource block for Vault providers.
|
||||
* @see https://registry.terraform.io/providers/hashicorp/vault/latest/docs
|
||||
*/
|
||||
export const terraformTemplate = ({
|
||||
resource = '',
|
||||
localId = '<local identifier>',
|
||||
options,
|
||||
}: TerraformTemplateArgs) => {
|
||||
const formattedContent = formatTerraformOptions(options);
|
||||
return `resource "${resource}" "${localId}" {
|
||||
${formattedContent.join('\n\n')}
|
||||
}`;
|
||||
};
|
||||
|
||||
export const formatTerraformOptions = (options: TerraformOptions) => {
|
||||
const argReferences = [];
|
||||
for (const [key, value] of Object.entries(options)) {
|
||||
// Additional spaces before "key" are so argument references are indented over
|
||||
argReferences.push(` ${key} = ${value}`);
|
||||
}
|
||||
return argReferences;
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export { default } from 'core/components/code-generator/policy/builder';
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export { default } from 'core/components/code-generator/policy/stanza';
|
||||
|
|
@ -34,6 +34,7 @@ export const GENERAL = {
|
|||
confirmButton: '[data-test-confirm-button]', // used most often on modal or confirm popups
|
||||
confirmTrigger: '[data-test-confirm-action-trigger]',
|
||||
copyButton: '[data-test-copy-button]',
|
||||
revealButton: (label: string) => `[data-test-reveal="${label}"] button`, // intended for Hds::Reveal components
|
||||
// there should only be one submit button per view (e.g. one per form) so this does not need to be dynamic
|
||||
// this button should be used for any kind of "submit" on a form or "save" action.
|
||||
submitButton: '[data-test-submit]',
|
||||
|
|
@ -169,7 +170,8 @@ export const GENERAL = {
|
|||
},
|
||||
|
||||
/* ────── Cards ────── */
|
||||
cardContainer: (title: string) => `[data-test-card-container="${title}"]`,
|
||||
cardContainer: (title: string) =>
|
||||
title ? `[data-test-card-container="${title}"]` : '[data-test-card-container]',
|
||||
|
||||
/* ────── Modals & Flyouts ────── */
|
||||
flyout: '[data-test-flyout]',
|
||||
|
|
|
|||
|
|
@ -3,15 +3,14 @@
|
|||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
const root = ['create', 'read', 'update', 'delete', 'list', 'patch'];
|
||||
import { ACL_CAPABILITIES, formatCapabilities } from 'core/utils/code-generators/policy';
|
||||
|
||||
// returns a string with each capability wrapped in double quotes => ["create", "read"]
|
||||
const format = (array) => array.map((c) => `"${c}"`).join(', ');
|
||||
const root = ACL_CAPABILITIES;
|
||||
|
||||
export const adminPolicy = (backend) => {
|
||||
return `
|
||||
path "${backend}/*" {
|
||||
capabilities = [${format(root)}]
|
||||
capabilities = [${formatCapabilities(root)}]
|
||||
},
|
||||
`;
|
||||
};
|
||||
|
|
@ -20,7 +19,7 @@ export const dataPolicy = ({ backend, secretPath = '*', capabilities = root }) =
|
|||
// "delete" capability on this path can delete latest version
|
||||
return `
|
||||
path "${backend}/data/${secretPath}" {
|
||||
capabilities = [${format(capabilities)}]
|
||||
capabilities = [${formatCapabilities(capabilities)}]
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
|
@ -36,7 +35,7 @@ export const subkeysPolicy = ({ backend, secretPath = '*' }) => {
|
|||
export const dataNestedPolicy = ({ backend, secretPath = '*', capabilities = root }) => {
|
||||
return `
|
||||
path "${backend}/data/app/${secretPath}" {
|
||||
capabilities = [${format(capabilities)}]
|
||||
capabilities = [${formatCapabilities(capabilities)}]
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
|
@ -45,7 +44,7 @@ export const metadataPolicy = ({ backend, secretPath = '*', capabilities = root
|
|||
// "delete" capability on this path can destroy all versions
|
||||
return `
|
||||
path "${backend}/metadata/${secretPath}" {
|
||||
capabilities = [${format(capabilities)}]
|
||||
capabilities = [${formatCapabilities(capabilities)}]
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
|
@ -53,7 +52,7 @@ export const metadataPolicy = ({ backend, secretPath = '*', capabilities = root
|
|||
export const metadataNestedPolicy = ({ backend, secretPath = '*', capabilities = root }) => {
|
||||
return `
|
||||
path "${backend}/metadata/app/${secretPath}" {
|
||||
capabilities = [${format(capabilities)}]
|
||||
capabilities = [${formatCapabilities(capabilities)}]
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,301 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, click, fillIn } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
import Sinon from 'sinon';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import { ACL_CAPABILITIES } from 'core/utils/code-generators/policy';
|
||||
|
||||
module('Integration | Component | code-generator/policy/builder', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.onPolicyChange = Sinon.spy();
|
||||
this.policyName = undefined;
|
||||
this.renderComponent = () => {
|
||||
return render(hbs`
|
||||
<CodeGenerator::Policy::Builder @onPolicyChange={{this.onPolicyChange}} @policyName={{this.policyName}} />`);
|
||||
};
|
||||
|
||||
this.assertPolicyUpdate = (assert, expected, message) => {
|
||||
const [policy] = this.onPolicyChange.lastCall.args;
|
||||
assert.strictEqual(policy, expected, `onPolicyChange is called ${message}`);
|
||||
};
|
||||
|
||||
this.assertEmptyTemplate = async (assert, { index } = {}) => {
|
||||
const container = index ? GENERAL.cardContainer(index) : '';
|
||||
assert.dom(`${container} ${GENERAL.inputByAttr('path')}`).hasValue('');
|
||||
assert.dom(`${container} ${GENERAL.toggleInput('preview')}`).isNotChecked();
|
||||
ACL_CAPABILITIES.forEach((capability) => {
|
||||
assert.dom(`${container} ${GENERAL.checkboxByAttr(capability)}`).isNotChecked();
|
||||
});
|
||||
// check empty preview state
|
||||
await click(`${container} ${GENERAL.toggleInput('preview')}`);
|
||||
const expectedPreview = `path "" {
|
||||
capabilities = []
|
||||
}`;
|
||||
assert
|
||||
.dom(GENERAL.fieldByAttr('preview'))
|
||||
.exists('it renders preview')
|
||||
.hasText(expectedPreview, 'preview is empty');
|
||||
};
|
||||
});
|
||||
|
||||
test('it renders', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await this.assertEmptyTemplate(assert);
|
||||
assert.dom(GENERAL.button('Add rule')).exists({ count: 1 });
|
||||
assert.dom(GENERAL.revealButton('Automation snippets')).hasAttribute('aria-expanded', 'false');
|
||||
await click(GENERAL.revealButton('Automation snippets'));
|
||||
assert.dom(GENERAL.revealButton('Automation snippets')).hasAttribute('aria-expanded', 'true');
|
||||
assert.dom(GENERAL.inputByAttr('terraform')).isChecked();
|
||||
assert.dom(GENERAL.inputByAttr('cli')).isNotChecked();
|
||||
});
|
||||
|
||||
test('it renders default snippets', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await click(GENERAL.revealButton('Automation snippets'));
|
||||
let expectedSnippet = `resource "vault_policy" "<local identifier>" {
|
||||
name = "<policy name>"
|
||||
|
||||
policy = <<EOT
|
||||
path "" {
|
||||
capabilities = []
|
||||
}
|
||||
EOT
|
||||
}`;
|
||||
assert
|
||||
.dom(GENERAL.fieldByAttr('snippets'))
|
||||
.hasText(expectedSnippet, 'it renders empty terraform snippet');
|
||||
|
||||
expectedSnippet = `vault policy write <policy name> - <<EOT
|
||||
path "" {
|
||||
capabilities = []
|
||||
}
|
||||
EOT`;
|
||||
await click(GENERAL.inputByAttr('cli'));
|
||||
assert.dom(GENERAL.inputByAttr('cli')).isChecked();
|
||||
assert.dom(GENERAL.inputByAttr('terraform')).isNotChecked();
|
||||
assert.dom(GENERAL.fieldByAttr('snippets')).hasText(expectedSnippet, 'it renders empty cli snippet');
|
||||
});
|
||||
|
||||
test('it includes namespace in snippet for non-root namespaces', async function (assert) {
|
||||
const namespace = this.owner.lookup('service:namespace');
|
||||
namespace.path = 'admin';
|
||||
await this.renderComponent();
|
||||
await click(GENERAL.revealButton('Automation snippets'));
|
||||
const expectedSnippet = `resource "vault_policy" "<local identifier>" {
|
||||
namespace = "admin"
|
||||
|
||||
name = "<policy name>"
|
||||
|
||||
policy = <<EOT
|
||||
path "" {
|
||||
capabilities = []
|
||||
}
|
||||
EOT
|
||||
}`;
|
||||
assert
|
||||
.dom(GENERAL.fieldByAttr('snippets'))
|
||||
.hasText(expectedSnippet, 'it renders empty terraform snippet');
|
||||
});
|
||||
|
||||
test('it does not call onPolicyChange when callback is not provided', async function (assert) {
|
||||
this.onPolicyChange = undefined;
|
||||
await this.renderComponent();
|
||||
await fillIn(GENERAL.inputByAttr('path'), 'test/path');
|
||||
await click(GENERAL.checkboxByAttr('read'));
|
||||
await click(GENERAL.button('Add rule'));
|
||||
assert.true(true, 'no errors are thrown when callback is undefined');
|
||||
});
|
||||
|
||||
test('it adds a rule', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await click(GENERAL.button('Add rule'));
|
||||
assert
|
||||
.dom(GENERAL.cardContainer())
|
||||
.exists({ count: 2 }, 'two templates render after clicking "Add rule"');
|
||||
await this.assertEmptyTemplate(assert, { index: '1' });
|
||||
});
|
||||
|
||||
test('it deletes a rule', async function (assert) {
|
||||
await this.renderComponent();
|
||||
assert.dom(GENERAL.cardContainer()).exists({ count: 1 });
|
||||
// Fill in template
|
||||
await fillIn(GENERAL.inputByAttr('path'), 'some/api/path');
|
||||
await click(GENERAL.checkboxByAttr('patch'));
|
||||
// Delete the only rendered template
|
||||
await click(GENERAL.button('Delete'));
|
||||
// One template renders but content should reset
|
||||
assert
|
||||
.dom(GENERAL.cardContainer())
|
||||
.exists({ count: 1 }, 'it still renders one rule after deleting the only rule');
|
||||
await this.assertEmptyTemplate(assert);
|
||||
});
|
||||
|
||||
test('it maintains state across multiple rules', async function (assert) {
|
||||
await this.renderComponent();
|
||||
// Set up first rule
|
||||
await fillIn(GENERAL.inputByAttr('path'), 'first/path');
|
||||
await click(GENERAL.checkboxByAttr('read'));
|
||||
// Add second rule
|
||||
await click(GENERAL.button('Add rule'));
|
||||
await fillIn(`${GENERAL.cardContainer('1')} ${GENERAL.inputByAttr('path')}`, 'second/path');
|
||||
await click(`${GENERAL.cardContainer('1')} ${GENERAL.checkboxByAttr('update')}`);
|
||||
|
||||
assert.dom(`${GENERAL.cardContainer('0')} ${GENERAL.inputByAttr('path')}`).hasValue('first/path');
|
||||
assert.dom(`${GENERAL.cardContainer('0')} ${GENERAL.checkboxByAttr('read')}`).isChecked();
|
||||
assert.dom(`${GENERAL.cardContainer('0')} ${GENERAL.checkboxByAttr('update')}`).isNotChecked();
|
||||
assert.dom(`${GENERAL.cardContainer('1')} ${GENERAL.inputByAttr('path')}`).hasValue('second/path');
|
||||
assert.dom(`${GENERAL.cardContainer('1')} ${GENERAL.checkboxByAttr('update')}`).isChecked();
|
||||
assert.dom(`${GENERAL.cardContainer('1')} ${GENERAL.checkboxByAttr('read')}`).isNotChecked();
|
||||
});
|
||||
|
||||
test('it deletes the correct rule when multiple exist', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await fillIn(GENERAL.inputByAttr('path'), 'first/path');
|
||||
await click(GENERAL.checkboxByAttr('read'));
|
||||
// Second rule
|
||||
await click(GENERAL.button('Add rule'));
|
||||
await fillIn(`${GENERAL.cardContainer('1')} ${GENERAL.inputByAttr('path')}`, 'second/path');
|
||||
await click(`${GENERAL.cardContainer('1')} ${GENERAL.checkboxByAttr('update')}`);
|
||||
// Third rule
|
||||
await click(GENERAL.button('Add rule'));
|
||||
await fillIn(`${GENERAL.cardContainer('2')} ${GENERAL.inputByAttr('path')}`, 'third/path');
|
||||
await click(`${GENERAL.cardContainer('2')} ${GENERAL.checkboxByAttr('list')}`);
|
||||
assert.dom(GENERAL.cardContainer()).exists({ count: 3 });
|
||||
// Delete middle rule
|
||||
await click(`${GENERAL.cardContainer('1')} ${GENERAL.button('Delete')}`);
|
||||
assert.dom(GENERAL.cardContainer()).exists({ count: 2 });
|
||||
assert.dom(`${GENERAL.cardContainer('0')} ${GENERAL.inputByAttr('path')}`).hasValue('first/path');
|
||||
assert.dom(`${GENERAL.cardContainer('0')} ${GENERAL.checkboxByAttr('read')}`).isChecked();
|
||||
assert.dom(`${GENERAL.cardContainer('1')} ${GENERAL.inputByAttr('path')}`).hasValue('third/path');
|
||||
assert.dom(`${GENERAL.cardContainer('1')} ${GENERAL.checkboxByAttr('list')}`).isChecked();
|
||||
});
|
||||
|
||||
test('it updates snippets', async function (assert) {
|
||||
this.policyName = 'my-secure-policy';
|
||||
await this.renderComponent();
|
||||
await fillIn(GENERAL.inputByAttr('path'), 'my/super/secret/*');
|
||||
await click(GENERAL.checkboxByAttr('patch'));
|
||||
await click(GENERAL.revealButton('Automation snippets'));
|
||||
// Check terraform snippet
|
||||
let expectedSnippet = `resource "vault_policy" "<local identifier>" {
|
||||
name = "my-secure-policy"
|
||||
|
||||
policy = <<EOT
|
||||
path "my/super/secret/*" {
|
||||
capabilities = ["patch"]
|
||||
}
|
||||
EOT
|
||||
}`;
|
||||
assert.dom(GENERAL.fieldByAttr('snippets')).hasText(expectedSnippet, 'it renders terraform snippet');
|
||||
|
||||
// Check CLI snippet
|
||||
expectedSnippet = `vault policy write my-secure-policy - <<EOT
|
||||
path "my/super/secret/*" {
|
||||
capabilities = ["patch"]
|
||||
}
|
||||
EOT`;
|
||||
await click(GENERAL.inputByAttr('cli'));
|
||||
assert.dom(GENERAL.fieldByAttr('snippets')).hasText(expectedSnippet, 'it renders cli snippet');
|
||||
});
|
||||
|
||||
test('it passes policy updates as changes are made', async function (assert) {
|
||||
await this.renderComponent();
|
||||
// Inputting path triggers callback
|
||||
await fillIn(GENERAL.inputByAttr('path'), 'my/super/secret/*');
|
||||
let expectedPolicy = `path "my/super/secret/*" {
|
||||
capabilities = []
|
||||
}`;
|
||||
|
||||
this.assertPolicyUpdate(assert, expectedPolicy, 'when path changes');
|
||||
|
||||
// Clicking checkbox triggers callback
|
||||
await click(GENERAL.checkboxByAttr('update'));
|
||||
expectedPolicy = `path "my/super/secret/*" {
|
||||
capabilities = ["update"]
|
||||
}`;
|
||||
|
||||
this.assertPolicyUpdate(assert, expectedPolicy, 'when a capability is selected');
|
||||
|
||||
// Adding a rule triggers callback
|
||||
await click(GENERAL.button('Add rule'));
|
||||
expectedPolicy = `path "my/super/secret/*" {
|
||||
capabilities = ["update"]
|
||||
}
|
||||
path "" {
|
||||
capabilities = []
|
||||
}`;
|
||||
this.assertPolicyUpdate(assert, expectedPolicy, 'when a rule is added');
|
||||
|
||||
// Updating added rule triggers callback
|
||||
await fillIn(`${GENERAL.cardContainer('1')} ${GENERAL.inputByAttr('path')}`, 'prod/');
|
||||
await click(`${GENERAL.cardContainer('1')} ${GENERAL.checkboxByAttr('list')}`);
|
||||
await click(`${GENERAL.cardContainer('1')} ${GENERAL.checkboxByAttr('read')}`);
|
||||
expectedPolicy = `path "my/super/secret/*" {
|
||||
capabilities = ["update"]
|
||||
}
|
||||
path "prod/" {
|
||||
capabilities = ["read", "list"]
|
||||
}`;
|
||||
this.assertPolicyUpdate(assert, expectedPolicy, 'when an additional rule updates');
|
||||
|
||||
// Unchecking box triggers callback
|
||||
await click(`${GENERAL.cardContainer('1')} ${GENERAL.checkboxByAttr('read')}`);
|
||||
expectedPolicy = `path "my/super/secret/*" {
|
||||
capabilities = ["update"]
|
||||
}
|
||||
path "prod/" {
|
||||
capabilities = ["list"]
|
||||
}`;
|
||||
this.assertPolicyUpdate(assert, expectedPolicy, 'when checkbox is unselected');
|
||||
|
||||
// Deleting a rule triggers callback
|
||||
await click(GENERAL.button('Delete'));
|
||||
expectedPolicy = `path "prod/" {
|
||||
capabilities = ["list"]
|
||||
}`;
|
||||
this.assertPolicyUpdate(assert, expectedPolicy, 'when a rule is deleted');
|
||||
});
|
||||
|
||||
// These tests ensure paths are never used as input identifiers.
|
||||
// The policy generator may not render in a form and needs to be flexible so it intentionally supports
|
||||
// multiple templates with the same or no path.
|
||||
test('it supports multiple rules with the same path', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await fillIn(GENERAL.inputByAttr('path'), 'test/path');
|
||||
await click(GENERAL.checkboxByAttr('patch'));
|
||||
await click(GENERAL.button('Add rule'));
|
||||
await fillIn(`${GENERAL.cardContainer('1')} ${GENERAL.inputByAttr('path')}`, 'test/path');
|
||||
await click(`${GENERAL.cardContainer('1')} ${GENERAL.checkboxByAttr('update')}`);
|
||||
|
||||
const expectedPolicy = `path "test/path" {
|
||||
capabilities = ["patch"]
|
||||
}
|
||||
path "test/path" {
|
||||
capabilities = ["update"]
|
||||
}`;
|
||||
this.assertPolicyUpdate(assert, expectedPolicy, 'when rules have the same path');
|
||||
});
|
||||
|
||||
test('it supports multiple rules with an empty path', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await click(GENERAL.checkboxByAttr('list'));
|
||||
await click(GENERAL.button('Add rule'));
|
||||
await click(`${GENERAL.cardContainer('1')} ${GENERAL.checkboxByAttr('delete')}`);
|
||||
|
||||
const expectedPolicy = `path "" {
|
||||
capabilities = ["list"]
|
||||
}
|
||||
path "" {
|
||||
capabilities = ["delete"]
|
||||
}`;
|
||||
this.assertPolicyUpdate(assert, expectedPolicy, 'when rules do have an empty path');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, click, fillIn, typeIn } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
import Sinon from 'sinon';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import { ACL_CAPABILITIES, PolicyStanza } from 'core/utils/code-generators/policy';
|
||||
|
||||
module('Integration | Component | code-generator/policy/stanza', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.stanza = new PolicyStanza();
|
||||
this.onDelete = Sinon.spy();
|
||||
this.onChange = Sinon.spy();
|
||||
|
||||
this.renderComponent = () => {
|
||||
return render(hbs`
|
||||
<CodeGenerator::Policy::Stanza
|
||||
@index="0"
|
||||
@onChange={{this.onChange}}
|
||||
@onDelete={{this.onDelete}}
|
||||
@stanza={{this.stanza}}
|
||||
/>`);
|
||||
};
|
||||
});
|
||||
|
||||
test('it renders', async function (assert) {
|
||||
await this.renderComponent();
|
||||
assert
|
||||
.dom(GENERAL.inputByAttr('path'))
|
||||
.hasValue('')
|
||||
.hasAttribute('placeholder', 'Enter a resource path')
|
||||
.hasAttribute('aria-label', 'Resource path')
|
||||
.hasAttribute('autocomplete', 'off');
|
||||
assert.dom(GENERAL.inputByAttr('path')).hasValue('');
|
||||
assert.dom(GENERAL.button('Delete')).exists({ count: 1 });
|
||||
// Assert checkboxes
|
||||
assert.dom('fieldset input[type="checkbox"]').exists({ count: 7 }, 'it renders 7 checkboxes');
|
||||
ACL_CAPABILITIES.forEach((capability) => {
|
||||
assert.dom(GENERAL.fieldLabel(capability)).hasText(capability);
|
||||
assert.dom(GENERAL.checkboxByAttr(capability)).isNotChecked();
|
||||
});
|
||||
// Assert preview toggle
|
||||
assert.dom(GENERAL.toggleInput('preview')).exists().isNotChecked();
|
||||
assert.dom(GENERAL.fieldLabel('preview')).hasText('Show preview');
|
||||
// Check empty preview state
|
||||
await click(GENERAL.toggleInput('preview'));
|
||||
assert.dom(GENERAL.toggleInput('preview')).isChecked();
|
||||
assert.dom(GENERAL.fieldLabel('preview')).hasText('Hide preview');
|
||||
const expectedPreview = `path "" {
|
||||
capabilities = []
|
||||
}`;
|
||||
assert.dom(GENERAL.fieldByAttr('preview')).hasText(expectedPreview);
|
||||
});
|
||||
|
||||
test('it renders policy preview', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await fillIn(GENERAL.inputByAttr('path'), 'some/api/path');
|
||||
await click(GENERAL.checkboxByAttr('update'));
|
||||
await click(GENERAL.checkboxByAttr('patch'));
|
||||
await click(GENERAL.toggleInput('preview'));
|
||||
let expectedPreview = `path "some/api/path" {
|
||||
capabilities = ["update", "patch"]
|
||||
}`;
|
||||
assert.dom(GENERAL.fieldByAttr('preview')).hasText(expectedPreview, 'it renders initial preview');
|
||||
// Toggle back to add more capabilities then check preview again
|
||||
await click(GENERAL.toggleInput('preview'));
|
||||
await typeIn(GENERAL.inputByAttr('path'), '/*');
|
||||
await click(GENERAL.checkboxByAttr('patch')); // uncheck
|
||||
await click(GENERAL.checkboxByAttr('list')); // check new
|
||||
// Confirm policy preview updated
|
||||
await click(GENERAL.toggleInput('preview'));
|
||||
expectedPreview = `path "some/api/path/*" {
|
||||
capabilities = ["update", "list"]
|
||||
}`;
|
||||
assert.dom(GENERAL.fieldByAttr('preview')).hasText(expectedPreview, 'it updates preview');
|
||||
});
|
||||
|
||||
test('it maintains checkbox state when toggling to show and hide preview', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await fillIn(GENERAL.inputByAttr('path'), 'some/api/path');
|
||||
await click(GENERAL.checkboxByAttr('update'));
|
||||
assert.dom(GENERAL.checkboxByAttr('update')).isChecked();
|
||||
// Toggle to show preview
|
||||
await click(GENERAL.toggleInput('preview'));
|
||||
assert.dom(GENERAL.toggleInput('preview')).isChecked();
|
||||
// Toggle back to checkboxes
|
||||
await click(GENERAL.toggleInput('preview'));
|
||||
assert.dom(GENERAL.toggleInput('preview')).isNotChecked();
|
||||
assert.dom(GENERAL.checkboxByAttr('update')).isChecked('update is still checked after viewing preview');
|
||||
});
|
||||
|
||||
test('it selects and unselects capabilities', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await click(GENERAL.checkboxByAttr('update')); // first onChange call
|
||||
assert.dom(GENERAL.checkboxByAttr('update')).isChecked();
|
||||
let expectedSet = new Set(['update']);
|
||||
assert.deepEqual(
|
||||
this.stanza.capabilities,
|
||||
expectedSet,
|
||||
`has expected capabilities: ${[...expectedSet].join(', ')}`
|
||||
);
|
||||
// Check "delete"
|
||||
await click(GENERAL.checkboxByAttr('delete')); // second onChange call
|
||||
assert.dom(GENERAL.checkboxByAttr('delete')).isChecked();
|
||||
expectedSet = new Set(['update', 'delete']);
|
||||
assert.deepEqual(
|
||||
this.stanza.capabilities,
|
||||
expectedSet,
|
||||
`has expected capabilities: ${[...expectedSet].join(', ')}`
|
||||
);
|
||||
// Uncheck "delete"
|
||||
await click(GENERAL.checkboxByAttr('delete')); // third onChange call
|
||||
assert.dom(GENERAL.checkboxByAttr('delete')).isNotChecked();
|
||||
expectedSet = new Set(['update']);
|
||||
assert.deepEqual(
|
||||
this.stanza.capabilities,
|
||||
expectedSet,
|
||||
`has expected capabilities: ${[...expectedSet].join(', ')}`
|
||||
);
|
||||
assert.strictEqual(this.onChange.callCount, 3, 'onChange is called every time a capability is selected');
|
||||
});
|
||||
|
||||
test('it selects all capabilities and updates @stanza', async function (assert) {
|
||||
await this.renderComponent();
|
||||
// check in random order to assert generator orders them
|
||||
for (const capability of ['list', 'read', 'sudo', 'create', 'delete', 'patch', 'update']) {
|
||||
await click(GENERAL.checkboxByAttr(capability));
|
||||
}
|
||||
await click(GENERAL.toggleInput('preview'));
|
||||
const expectedPreview = `path "" {
|
||||
capabilities = ["create", "read", "update", "delete", "list", "patch", "sudo"]
|
||||
}`;
|
||||
assert.dom(GENERAL.fieldByAttr('preview')).hasText(expectedPreview);
|
||||
assert.deepEqual(
|
||||
this.stanza.capabilities,
|
||||
new Set(['create', 'read', 'update', 'delete', 'list', 'patch', 'sudo']),
|
||||
'stanza includes every capability, in order'
|
||||
);
|
||||
});
|
||||
|
||||
test('it updates @stanza when path changes', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await typeIn(GENERAL.inputByAttr('path'), 'my/super/secret/*');
|
||||
assert.strictEqual(this.stanza.path, 'my/super/secret/*', '"path" is updated');
|
||||
});
|
||||
|
||||
// This whole test will fail if conditionals around @onChange are problematically refactored
|
||||
test('it does not call onChange when callback is not provided', async function (assert) {
|
||||
this.onChange = undefined;
|
||||
await this.renderComponent();
|
||||
await fillIn(GENERAL.inputByAttr('path'), 'test/path');
|
||||
await click(GENERAL.checkboxByAttr('read'));
|
||||
assert.true(true, 'no errors are thrown when callback is undefined');
|
||||
});
|
||||
|
||||
test('it calls onChange when path changes', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await fillIn(GENERAL.inputByAttr('path'), 'my/super/secret/*');
|
||||
assert.true(this.onChange.calledOnce, 'onChange is called');
|
||||
});
|
||||
|
||||
test('it calls onChange when a checkbox is selected', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await click(GENERAL.checkboxByAttr('update'));
|
||||
assert.true(this.onChange.calledOnce, 'onChange is called');
|
||||
});
|
||||
|
||||
test('it calls onDelete', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await click(GENERAL.button('Delete'));
|
||||
assert.true(this.onDelete.calledOnce, 'onDelete is called');
|
||||
});
|
||||
});
|
||||
29
ui/tests/integration/utils/code-generators/cli-test.js
Normal file
29
ui/tests/integration/utils/code-generators/cli-test.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import { formatCli } from 'core/utils/code-generators/cli';
|
||||
|
||||
module('Integration | Util | code-generators/cli', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('formatCli: it formats CLI command with content', async function (assert) {
|
||||
const content = `- <<EOT
|
||||
path "secret/*" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
EOT`;
|
||||
const formatted = formatCli({ command: 'policy write my-policy', content: content });
|
||||
const expected = `vault policy write my-policy ${content}`;
|
||||
assert.strictEqual(formatted, expected, 'it formats CLI command with content');
|
||||
});
|
||||
|
||||
test('formatCli: it handles empty content', async function (assert) {
|
||||
const formatted = formatCli({ command: 'policy list', content: '' });
|
||||
const expected = 'vault policy list';
|
||||
assert.strictEqual(formatted, expected, 'it formats CLI command with empty content');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import { formatEot } from 'core/utils/code-generators/formatters';
|
||||
|
||||
module('Integration | Util | code-generators/formatters', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('formatEot: it wraps content in EOT heredoc block', async function (assert) {
|
||||
const content = 'path "secret/*" {\n capabilities = ["read"]\n}';
|
||||
const formatted = formatEot(content);
|
||||
const expected = `<<EOT
|
||||
path "secret/*" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
EOT`;
|
||||
assert.strictEqual(formatted, expected, 'it wraps content with line breaks "\n"');
|
||||
});
|
||||
|
||||
test('formatEot: it handles single line content', async function (assert) {
|
||||
const formatted = formatEot('single line content');
|
||||
const expected = `<<EOT
|
||||
single line content
|
||||
EOT`;
|
||||
assert.strictEqual(formatted, expected, 'it wraps single line');
|
||||
});
|
||||
|
||||
test('formatEot: it handles empty content', async function (assert) {
|
||||
const formatted = formatEot('');
|
||||
const expected = `<<EOT
|
||||
|
||||
EOT`;
|
||||
assert.strictEqual(formatted, expected, 'it wraps empty content');
|
||||
});
|
||||
|
||||
test('formatEot: it handles multi-line content', async function (assert) {
|
||||
const content = `line 1
|
||||
line 2
|
||||
line 3`;
|
||||
const formatted = formatEot(content);
|
||||
const expected = `<<EOT
|
||||
line 1
|
||||
line 2
|
||||
line 3
|
||||
EOT`;
|
||||
assert.strictEqual(formatted, expected, 'it wraps multi-line content');
|
||||
});
|
||||
});
|
||||
140
ui/tests/integration/utils/code-generators/policy-test.js
Normal file
140
ui/tests/integration/utils/code-generators/policy-test.js
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import {
|
||||
aclTemplate,
|
||||
formatCapabilities,
|
||||
isAclCapability,
|
||||
ACL_CAPABILITIES,
|
||||
PolicyStanza,
|
||||
} from 'core/utils/code-generators/policy';
|
||||
|
||||
module('Integration | Util | code-generators/policy', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('aclTemplate: it formats a policy', async function (assert) {
|
||||
const formatted = aclTemplate('my-path/*', ['list', 'read', 'delete']);
|
||||
const expected = `path "my-path/*" {
|
||||
capabilities = ["read", "delete", "list"]
|
||||
}`;
|
||||
assert.strictEqual(formatted, expected, 'it formats an ACL policy');
|
||||
});
|
||||
|
||||
test('aclTemplate: it handles empty path and capabilities', async function (assert) {
|
||||
const formatted = aclTemplate('', []);
|
||||
const expected = `path "" {
|
||||
capabilities = []
|
||||
}`;
|
||||
assert.strictEqual(formatted, expected, 'it formats empty policy');
|
||||
});
|
||||
|
||||
test('aclTemplate: it handles single capability', async function (assert) {
|
||||
const formatted = aclTemplate('auth/token/lookup-self', ['read']);
|
||||
const expected = `path "auth/token/lookup-self" {
|
||||
capabilities = ["read"]
|
||||
}`;
|
||||
assert.strictEqual(formatted, expected, 'it formats policy with single capability');
|
||||
});
|
||||
|
||||
test('formatCapabilities: it formats capabilities in consistent order', async function (assert) {
|
||||
const formatted = formatCapabilities(['list', 'read', 'delete']);
|
||||
const expected = '"read", "delete", "list"';
|
||||
assert.strictEqual(formatted, expected, 'it formats capabilities in ACL_CAPABILITIES order');
|
||||
});
|
||||
|
||||
test('formatCapabilities: it filters out invalid capabilities', async function (assert) {
|
||||
const formatted = formatCapabilities(['read', 'invalid', 'list']);
|
||||
const expected = '"read", "list"';
|
||||
assert.strictEqual(formatted, expected, 'it filters out invalid capabilities');
|
||||
});
|
||||
|
||||
test('formatCapabilities: it returns empty string for empty array', async function (assert) {
|
||||
const formatted = formatCapabilities([]);
|
||||
assert.strictEqual(formatted, '', 'it returns empty string for no capabilities');
|
||||
});
|
||||
|
||||
test('formatCapabilities: it handles single capability', async function (assert) {
|
||||
const formatted = formatCapabilities(['read']);
|
||||
const expected = '"read"';
|
||||
assert.strictEqual(formatted, expected, 'it formats single capability');
|
||||
});
|
||||
|
||||
test('formatCapabilities: it handles all capabilities', async function (assert) {
|
||||
const sorted = [...ACL_CAPABILITIES].sort(); // alphabetize so input order is different than expected output
|
||||
const formatted = formatCapabilities(sorted);
|
||||
const expected = '"create", "read", "update", "delete", "list", "patch", "sudo"';
|
||||
assert.strictEqual(formatted, expected, 'it formats all capabilities in order');
|
||||
});
|
||||
|
||||
test('isAclCapability: it returns true for valid capabilities', async function (assert) {
|
||||
ACL_CAPABILITIES.forEach((cap) => {
|
||||
assert.true(isAclCapability(cap), `${cap} is a valid capability`);
|
||||
});
|
||||
});
|
||||
|
||||
test('isAclCapability: it returns false for invalid capabilities', async function (assert) {
|
||||
assert.false(isAclCapability('invalid'), 'invalid is not a valid capability');
|
||||
assert.false(isAclCapability('write'), 'write is not a valid capability');
|
||||
assert.false(isAclCapability(''), 'empty string is not a valid capability');
|
||||
assert.false(isAclCapability('READ'), 'uppercase READ is not a valid capability');
|
||||
});
|
||||
|
||||
test('PolicyStanza: it initializes with empty capabilities and path', async function (assert) {
|
||||
const stanza = new PolicyStanza();
|
||||
assert.strictEqual(stanza.path, '', 'path is empty');
|
||||
assert.strictEqual(stanza.capabilities.size, 0, 'capabilities set is empty');
|
||||
});
|
||||
|
||||
test('PolicyStanza: it generates preview for single capability', async function (assert) {
|
||||
const stanza = new PolicyStanza();
|
||||
stanza.path = 'secret/data/*';
|
||||
stanza.capabilities.add('read');
|
||||
|
||||
const expected = `path "secret/data/*" {
|
||||
capabilities = ["read"]
|
||||
}`;
|
||||
assert.strictEqual(stanza.preview, expected, 'it generates correct preview');
|
||||
});
|
||||
|
||||
test('PolicyStanza: it generates preview for multiple capabilities', async function (assert) {
|
||||
const stanza = new PolicyStanza();
|
||||
stanza.path = 'auth/*';
|
||||
stanza.capabilities.add('list');
|
||||
stanza.capabilities.add('read');
|
||||
stanza.capabilities.add('create');
|
||||
|
||||
const expected = `path "auth/*" {
|
||||
capabilities = ["create", "read", "list"]
|
||||
}`;
|
||||
assert.strictEqual(stanza.preview, expected, 'it generates preview with multiple capabilities');
|
||||
});
|
||||
|
||||
test('PolicyStanza: it generates preview without path and capabilities', async function (assert) {
|
||||
const stanza = new PolicyStanza();
|
||||
const expected = `path "" {
|
||||
capabilities = []
|
||||
}`;
|
||||
assert.strictEqual(stanza.preview, expected, 'it generates preview with empty capabilities');
|
||||
});
|
||||
|
||||
test('PolicyStanza: it updates preview when capabilities change', async function (assert) {
|
||||
const stanza = new PolicyStanza();
|
||||
stanza.path = 'secret/*';
|
||||
stanza.capabilities.add('read');
|
||||
|
||||
const firstPreview = stanza.preview;
|
||||
|
||||
stanza.capabilities.add('list');
|
||||
const secondPreview = stanza.preview;
|
||||
const expected = `path "secret/*" {
|
||||
capabilities = ["read", "list"]
|
||||
}`;
|
||||
assert.notStrictEqual(firstPreview, secondPreview, 'preview updates when capabilities change');
|
||||
assert.true(secondPreview.includes('"read", "list"'), 'new preview includes both capabilities');
|
||||
assert.strictEqual(secondPreview, expected, 'new preview reflects updates');
|
||||
});
|
||||
});
|
||||
107
ui/tests/integration/utils/code-generators/terraform-test.js
Normal file
107
ui/tests/integration/utils/code-generators/terraform-test.js
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import { terraformTemplate, formatTerraformOptions } from 'core/utils/code-generators/terraform';
|
||||
|
||||
module('Integration | Util | code-generators/terraform', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('formatTerraformOptions: it formats single option', async function (assert) {
|
||||
const options = { type: '"github"' };
|
||||
const formatted = formatTerraformOptions(options);
|
||||
const expected = [' type = "github"'];
|
||||
assert.propEqual(formatted, expected, 'it formats single option correctly');
|
||||
});
|
||||
|
||||
test('formatTerraformOptions: it formats multiple options', async function (assert) {
|
||||
const options = {
|
||||
type: '"userpass"',
|
||||
path: '"userpass"',
|
||||
description: '"User password auth"',
|
||||
};
|
||||
const formatted = formatTerraformOptions(options);
|
||||
assert.strictEqual(formatted.length, 3, 'it returns array with 3 items');
|
||||
assert.true(formatted.includes(' type = "userpass"'), 'it includes type option');
|
||||
assert.true(formatted.includes(' path = "userpass"'), 'it includes path option');
|
||||
assert.true(formatted.includes(' description = "User password auth"'), 'it includes description option');
|
||||
});
|
||||
|
||||
test('formatTerraformOptions: it formats options with numbers', async function (assert) {
|
||||
const options = { max_ttl: 3600 };
|
||||
const formatted = formatTerraformOptions(options);
|
||||
const expected = [' max_ttl = 3600'];
|
||||
assert.propEqual(formatted, expected, 'it formats numeric values');
|
||||
});
|
||||
|
||||
test('formatTerraformOptions: it handles empty options', async function (assert) {
|
||||
const options = {};
|
||||
const formatted = formatTerraformOptions(options);
|
||||
assert.propEqual(formatted, [], 'it returns empty array for empty options');
|
||||
});
|
||||
|
||||
test('terraformTemplate: it generates basic terraform resource', async function (assert) {
|
||||
const formatted = terraformTemplate({
|
||||
resource: 'vault_auth_backend',
|
||||
localId: 'example',
|
||||
options: { type: '"github"' },
|
||||
});
|
||||
const expected = `resource "vault_auth_backend" "example" {
|
||||
type = "github"
|
||||
}`;
|
||||
assert.strictEqual(formatted, expected, 'it generates terraform resource');
|
||||
});
|
||||
|
||||
test('terraformTemplate: it generates terraform resource with multiple options', async function (assert) {
|
||||
const formatted = terraformTemplate({
|
||||
resource: 'vault_mount',
|
||||
localId: 'kv',
|
||||
options: {
|
||||
path: '"secret"',
|
||||
type: '"kv-v2"',
|
||||
description: '"KV Version 2 secret engine"',
|
||||
},
|
||||
});
|
||||
|
||||
assert.true(formatted.includes('resource "vault_mount" "kv"'), 'it includes resource declaration');
|
||||
assert.true(formatted.includes('path = "secret"'), 'it includes path option');
|
||||
assert.true(formatted.includes('type = "kv-v2"'), 'it includes type option');
|
||||
assert.true(formatted.includes('description = "KV Version 2 secret engine"'), 'it includes description');
|
||||
});
|
||||
|
||||
test('terraformTemplate: it uses default localId when not provided', async function (assert) {
|
||||
const formatted = terraformTemplate({
|
||||
resource: 'vault_auth_backend',
|
||||
options: { type: '"github"' },
|
||||
});
|
||||
assert.true(formatted.includes('"<local identifier>"'), 'it uses default local identifier');
|
||||
});
|
||||
|
||||
test('terraformTemplate: it handles empty resource name', async function (assert) {
|
||||
const formatted = terraformTemplate({
|
||||
resource: '',
|
||||
localId: 'test',
|
||||
options: { key: '"value"' },
|
||||
});
|
||||
assert.true(formatted.includes('resource "" "test"'), 'it handles empty resource name');
|
||||
});
|
||||
|
||||
test('terraformTemplate: it formats multiple options with proper spacing', async function (assert) {
|
||||
const formatted = terraformTemplate({
|
||||
resource: 'vault_auth_backend',
|
||||
localId: 'userpass',
|
||||
options: {
|
||||
type: '"userpass"',
|
||||
path: '"userpass"',
|
||||
tune: '{}',
|
||||
},
|
||||
});
|
||||
|
||||
// Check that options are separated by double newlines
|
||||
const lines = formatted.split('\n\n');
|
||||
assert.strictEqual(lines.length, 3, 'options are separated by blank lines');
|
||||
});
|
||||
});
|
||||
11
ui/types/vault/utils/code-generators/terraform.d.ts
vendored
Normal file
11
ui/types/vault/utils/code-generators/terraform.d.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
// Argument references for a TFVP (terraform vault provider) resource
|
||||
interface TerraformOptions {
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
policy?: string;
|
||||
}
|
||||
Loading…
Reference in a new issue