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; +}