diff --git a/ui/app/styles/helper-classes/spacing.scss b/ui/app/styles/helper-classes/spacing.scss
index 14bc7071db..177efdad97 100644
--- a/ui/app/styles/helper-classes/spacing.scss
+++ b/ui/app/styles/helper-classes/spacing.scss
@@ -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;
}
diff --git a/ui/lib/core/addon/components/code-generator/policy/builder.hbs b/ui/lib/core/addon/components/code-generator/policy/builder.hbs
new file mode 100644
index 0000000000..034c4b16ec
--- /dev/null
+++ b/ui/lib/core/addon/components/code-generator/policy/builder.hbs
@@ -0,0 +1,48 @@
+{{!
+ Copyright IBM Corp. 2016, 2025
+ SPDX-License-Identifier: BUSL-1.1
+}}
+
+{{#each this.stanzas as |stanza idx|}}
+
+{{/each}}
+
+
+
+
+
+
+
+
+
+
+ {{#each-in this.snippetTypes as |value label|}}
+
+ {{label}}
+
+ {{/each-in}}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ui/lib/core/addon/components/code-generator/policy/builder.ts b/ui/lib/core/addon/components/code-generator/policy/builder.ts
new file mode 100644
index 0000000000..d0da22053d
--- /dev/null
+++ b/ui/lib/core/addon/components/code-generator/policy/builder.ts
@@ -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 {
+ @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 || '';
+ 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) {
+ const { value } = event.target;
+ this.snippetType = value;
+ }
+
+ updateStanzas(stanzas: PolicyStanza[]) {
+ // Trigger an update by reassigning tracked variable
+ this.stanzas = stanzas;
+ this.handleChange();
+ }
+}
diff --git a/ui/lib/core/addon/components/code-generator/policy/stanza.hbs b/ui/lib/core/addon/components/code-generator/policy/stanza.hbs
new file mode 100644
index 0000000000..1d5ae86718
--- /dev/null
+++ b/ui/lib/core/addon/components/code-generator/policy/stanza.hbs
@@ -0,0 +1,68 @@
+{{!
+ Copyright IBM Corp. 2016, 2025
+ SPDX-License-Identifier: BUSL-1.1
+}}
+
+
+
+
+ Rule
+
+
+ {{if this.showPreview "Hide" "Show"}} preview
+
+
+
+ {{#if this.showPreview}}
+
+ {{else}}
+
+
+
+
+
+
+ Capabilities
+ {{#each this.permissions as |capability|}}
+
+ {{capability}}
+
+ {{/each}}
+
+ {{/if}}
+
+
\ No newline at end of file
diff --git a/ui/lib/core/addon/components/code-generator/policy/stanza.ts b/ui/lib/core/addon/components/code-generator/policy/stanza.ts
new file mode 100644
index 0000000000..b7d7b0f401
--- /dev/null
+++ b/ui/lib/core/addon/components/code-generator/policy/stanza.ts
@@ -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 {
+ @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) {
+ this.args.stanza.path = event.target.value;
+ this.args.onChange?.();
+ }
+
+ @action
+ setPermissions(event: HTMLElementEvent) {
+ 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?.();
+ }
+ }
+}
diff --git a/ui/lib/core/addon/utils/code-generators/cli.ts b/ui/lib/core/addon/utils/code-generators/cli.ts
new file mode 100644
index 0000000000..ee23c314a6
--- /dev/null
+++ b/ui/lib/core/addon/utils/code-generators/cli.ts
@@ -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}`;
diff --git a/ui/lib/core/addon/utils/code-generators/formatters.ts b/ui/lib/core/addon/utils/code-generators/formatters.ts
new file mode 100644
index 0000000000..de12adeb7c
--- /dev/null
+++ b/ui/lib/core/addon/utils/code-generators/formatters.ts
@@ -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 `< = 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);
diff --git a/ui/lib/core/addon/utils/code-generators/terraform.ts b/ui/lib/core/addon/utils/code-generators/terraform.ts
new file mode 100644
index 0000000000..fca335ba6d
--- /dev/null
+++ b/ui/lib/core/addon/utils/code-generators/terraform.ts
@@ -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 = '',
+ 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;
+};
diff --git a/ui/lib/core/app/components/code-generator/policy/builder.js b/ui/lib/core/app/components/code-generator/policy/builder.js
new file mode 100644
index 0000000000..d3b21987cb
--- /dev/null
+++ b/ui/lib/core/app/components/code-generator/policy/builder.js
@@ -0,0 +1,6 @@
+/**
+ * Copyright IBM Corp. 2016, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+export { default } from 'core/components/code-generator/policy/builder';
diff --git a/ui/lib/core/app/components/code-generator/policy/stanza.js b/ui/lib/core/app/components/code-generator/policy/stanza.js
new file mode 100644
index 0000000000..59c45e8463
--- /dev/null
+++ b/ui/lib/core/app/components/code-generator/policy/stanza.js
@@ -0,0 +1,6 @@
+/**
+ * Copyright IBM Corp. 2016, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+export { default } from 'core/components/code-generator/policy/stanza';
diff --git a/ui/tests/helpers/general-selectors.ts b/ui/tests/helpers/general-selectors.ts
index fecebda45f..d8713c86de 100644
--- a/ui/tests/helpers/general-selectors.ts
+++ b/ui/tests/helpers/general-selectors.ts
@@ -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]',
diff --git a/ui/tests/helpers/kv/policy-generator.js b/ui/tests/helpers/kv/policy-generator.js
index 16441f155a..9648c6370b 100644
--- a/ui/tests/helpers/kv/policy-generator.js
+++ b/ui/tests/helpers/kv/policy-generator.js
@@ -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)}]
}
`;
};
diff --git a/ui/tests/integration/components/code-generator/policy/builder-test.js b/ui/tests/integration/components/code-generator/policy/builder-test.js
new file mode 100644
index 0000000000..abc8a48145
--- /dev/null
+++ b/ui/tests/integration/components/code-generator/policy/builder-test.js
@@ -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`
+ `);
+ };
+
+ 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" "" {
+ name = ""
+
+ policy = < - <" {
+ namespace = "admin"
+
+ name = ""
+
+ policy = <" {
+ name = "my-secure-policy"
+
+ policy = < {
+ return render(hbs`
+ `);
+ };
+ });
+
+ 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');
+ });
+});
diff --git a/ui/tests/integration/utils/code-generators/cli-test.js b/ui/tests/integration/utils/code-generators/cli-test.js
new file mode 100644
index 0000000000..6ea9bdf2de
--- /dev/null
+++ b/ui/tests/integration/utils/code-generators/cli-test.js
@@ -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 = `- < {
+ 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');
+ });
+});
diff --git a/ui/tests/integration/utils/code-generators/terraform-test.js b/ui/tests/integration/utils/code-generators/terraform-test.js
new file mode 100644
index 0000000000..967e3d54a2
--- /dev/null
+++ b/ui/tests/integration/utils/code-generators/terraform-test.js
@@ -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('""'), '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');
+ });
+});
diff --git a/ui/types/vault/utils/code-generators/terraform.d.ts b/ui/types/vault/utils/code-generators/terraform.d.ts
new file mode 100644
index 0000000000..49b1b3bbf4
--- /dev/null
+++ b/ui/types/vault/utils/code-generators/terraform.d.ts
@@ -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;
+}