/** * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: BUSL-1.1 */ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import { setupEngine } from 'ember-engines/test-support'; import { setupMirage } from 'ember-cli-mirage/test-support'; import hbs from 'htmlbars-inline-precompile'; import sinon from 'sinon'; import { Response } from 'miragejs'; import { click, fillIn, render, typeIn } from '@ember/test-helpers'; import { PAGE } from 'vault/tests/helpers/sync/sync-selectors'; import { syncDestinations } from 'vault/helpers/sync-destinations'; import { decamelize, underscore } from '@ember/string'; const SYNC_DESTINATIONS = syncDestinations(); module('Integration | Component | sync | Secrets::Page::Destinations::CreateAndEdit', function (hooks) { setupRenderingTest(hooks); setupEngine(hooks, 'sync'); setupMirage(hooks); hooks.beforeEach(function () { this.store = this.owner.lookup('service:store'); this.transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo'); this.clearDatasetStub = sinon.stub(this.store, 'clearDataset'); this.renderFormComponent = () => { return render(hbs` `, { owner: this.engine, }); }; this.generateModel = (type = 'aws-sm') => { const data = this.server.create('sync-destination', type); const id = `${type}/${data.name}`; data.id = id; this.store.pushPayload(`sync/destinations/${type}`, { modelName: `sync/destinations/${type}`, ...data, }); return this.store.peekRecord(`sync/destinations/${type}`, id); }; }); test('create: it renders and navigates back to create on cancel', async function (assert) { assert.expect(2); const { type } = SYNC_DESTINATIONS[0]; this.model = this.store.createRecord(`sync/destinations/${type}`, { type }); await this.renderFormComponent(); assert.dom(PAGE.breadcrumbs).hasText('Secrets Sync Select Destination Create Destination'); await click(PAGE.cancelButton); const transition = this.transitionStub.calledWith('vault.cluster.sync.secrets.destinations.create'); assert.true(transition, 'transitions to vault.cluster.sync.secrets.destinations.create on cancel'); }); test('edit: it renders and navigates back to details on cancel', async function (assert) { assert.expect(4); this.model = this.generateModel(); await this.renderFormComponent(); assert.dom(PAGE.breadcrumbs).hasText('Secrets Sync Destinations Destination Edit Destination'); assert.dom('h2').hasText('Credentials', 'renders credentials section on edit'); assert .dom('p.hds-foreground-faint') .hasText( 'Connection credentials are sensitive information and the value cannot be read. Enable the input to update.' ); await click(PAGE.cancelButton); const transition = this.transitionStub.calledWith('vault.cluster.sync.secrets.destinations.destination'); assert.true(transition, 'transitions to vault.cluster.sync.secrets.destinations.destination on cancel'); }); test('edit: it PATCH updates custom_tags', async function (assert) { assert.expect(1); this.model = this.generateModel(); this.server.patch(`sys/sync/destinations/${this.model.type}/${this.model.name}`, (schema, req) => { const payload = JSON.parse(req.requestBody); const expected = { tags_to_remove: ['foo'], custom_tags: { updated: 'bar', added: 'key' }, }; assert.propEqual(payload, expected, 'payload removes old tags and includes updated object'); return { payload }; }); // bypass form and manually set model attributes this.model.set('customTags', { updated: 'bar', added: 'key', }); await this.renderFormComponent(); await click(PAGE.saveButton); }); test('edit: it adds custom_tags when previously there are none', async function (assert) { assert.expect(1); this.model = this.generateModel(); this.server.patch(`sys/sync/destinations/${this.model.type}/${this.model.name}`, (schema, req) => { const payload = JSON.parse(req.requestBody); const expected = { custom_tags: { foo: 'blah' } }; assert.propEqual(payload, expected, 'payload contains new custom tags'); return { payload }; }); // bypass form and manually set model attributes this.model.set('customTags', {}); await this.renderFormComponent(); await PAGE.form.fillInByAttr('customTags', 'blah'); await click(PAGE.saveButton); }); test('edit: payload does not contain any custom_tags when removed in form', async function (assert) { assert.expect(1); this.model = this.generateModel(); this.server.patch(`sys/sync/destinations/${this.model.type}/${this.model.name}`, (schema, req) => { const payload = JSON.parse(req.requestBody); const expected = { tags_to_remove: ['foo'], custom_tags: {} }; assert.propEqual(payload, expected, 'payload removes old keys'); return { payload }; }); await this.renderFormComponent(); await click(PAGE.kvObjectEditor.deleteRow()); await click(PAGE.saveButton); }); test('edit: payload only contains masked inputs when they have changed', async function (assert) { assert.expect(1); this.model = this.generateModel(); this.server.patch(`sys/sync/destinations/${this.model.type}/${this.model.name}`, (schema, req) => { const payload = JSON.parse(req.requestBody); assert.propEqual( payload, { secret_access_key: 'new-secret' }, 'payload contains the changed obfuscated field' ); return { payload }; }); await this.renderFormComponent(); await click(PAGE.enableField('accessKeyId')); await click(PAGE.maskedInput('accessKeyId')); // click on input but do not change value await click(PAGE.enableField('secretAccessKey')); await fillIn(PAGE.maskedInput('secretAccessKey'), 'new-secret'); await click(PAGE.saveButton); }); test('it renders API errors', async function (assert) { assert.expect(1); const name = 'my-failed-dest'; const type = SYNC_DESTINATIONS[0].type; this.server.post(`sys/sync/destinations/${type}/${name}`, () => { return new Response( 500, {}, { errors: [ `1 error occurred: * couldn't create store node in syncer: failed to create store: unable to initialize store of type "azure-kv": failed to parse azure key vault URI: parse "my-unprasableuri": invalid URI for request`, ], } ); }); this.model = this.store.createRecord(`sync/destinations/${type}`, { name, type }); await this.renderFormComponent(); await click(PAGE.saveButton); assert .dom(PAGE.messageError) .hasText( `Error 1 error occurred: * couldn't create store node in syncer: failed to create store: unable to initialize store of type "azure-kv": failed to parse azure key vault URI: parse "my-unprasableuri": invalid URI for request` ); }); test('it renders warning validation only when editing vercel-project team_id', async function (assert) { assert.expect(2); const type = 'vercel-project'; // new model this.model = this.store.createRecord(`sync/destinations/${type}`, { type }); await this.renderFormComponent(); await typeIn(PAGE.inputByAttr('teamId'), 'id'); assert .dom(PAGE.validationWarning('teamId')) .doesNotExist('does not render warning validation for new vercel-project destination'); // existing model const data = this.server.create('sync-destination', type); const id = `${type}/${data.name}`; data.id = id; this.store.pushPayload(`sync/destinations/${type}`, { modelName: `sync/destinations/${type}`, ...data, }); this.model = this.store.peekRecord(`sync/destinations/${type}`, id); await this.renderFormComponent(); await PAGE.form.fillInByAttr('teamId', ''); await typeIn(PAGE.inputByAttr('teamId'), 'edit'); assert .dom(PAGE.validationWarning('teamId')) .hasText( 'Team ID should only be updated if the project was transferred to another account.', 'it renders validation warning' ); }); // CREATE FORM ASSERTIONS FOR EACH DESTINATION TYPE for (const destination of SYNC_DESTINATIONS) { const { name, type } = destination; const obfuscatedFields = ['accessToken', 'clientSecret', 'secretAccessKey', 'accessKeyId']; module(`create destination: ${type}`, function (hooks) { hooks.beforeEach(function () { this.model = this.store.createRecord(`sync/destinations/${type}`, { type }); }); test('it renders destination form', async function (assert) { assert.expect(this.model.formFields.length + 1); await this.renderFormComponent(); assert.dom(PAGE.title).hasTextContaining(`Create Destination for ${name}`); for (const attr of this.model.formFields) { assert.dom(PAGE.fieldByAttr(attr.name)).exists(); } }); test('it masks obfuscated fields', async function (assert) { const filteredObfuscatedFields = this.model.formFields.filter((field) => obfuscatedFields.includes(field.name) ); assert.expect(filteredObfuscatedFields.length); await this.renderFormComponent(); // iterate over the form fields and filter for those that are obfuscated // fill those in and assert that they are masked filteredObfuscatedFields.forEach(async (field) => { await fillIn(PAGE.maskedInput(field.name), 'blah'); assert .dom(PAGE.maskedInput(field.name)) .hasClass('masked-font', `it renders ${field.name} for ${destination} with masked font`); }); }); test('it saves destination and transitions to details', async function (assert) { assert.expect(5); const name = 'my-name'; this.server.post(`sys/sync/destinations/${type}/${name}`, (schema, req) => { const payload = JSON.parse(req.requestBody); assert.ok(true, `makes request: POST sys/sync/destinations/${type}/${name}`); assert.notPropContains(payload, { name: 'my-name', type }, 'name and type do not exist in payload'); // instead of looping through all attrs, just grab the second one (first is 'name') const testAttr = this.model.formFields[1].name; assert.propContains( payload, { [underscore(testAttr)]: `my-${testAttr}` }, 'payload contains expected attrs' ); return payload; }); await this.renderFormComponent(); for (const attr of this.model.formFields) { await PAGE.form.fillInByAttr(attr.name, `my-${attr.name}`); } await click(PAGE.saveButton); const actualArgs = this.transitionStub.lastCall.args; const expectedArgs = ['vault.cluster.sync.secrets.destinations.destination.details', type, name]; assert.propEqual(actualArgs, expectedArgs, 'transitionTo called with expected args'); assert.propEqual( this.clearDatasetStub.lastCall.args, ['sync/destination'], 'Store dataset is cleared on create success' ); }); test('it validates inputs', async function (assert) { const warningValidations = ['teamId']; const validationAssertions = this.model._validations; // remove warning validations to warningValidations.forEach((warning) => { delete validationAssertions[warning]; }); assert.expect(Object.keys(validationAssertions).length); await this.renderFormComponent(); await click(PAGE.saveButton); // only asserts validations for presence, refactor if validations change for (const attr in validationAssertions) { const { message } = validationAssertions[attr].find((v) => v.type === 'presence'); assert.dom(PAGE.validation(attr)).hasText(message, `renders validation: ${message}`); } }); }); } // EDIT FORM ASSERTIONS FOR EACH DESTINATION TYPE // * test updates: if editable, add param here // if it is not a string type, add case to EXPECTED_VALUE and update // fillInByAttr() (in sync-selectors) to interact with the form const EDITABLE_FIELDS = { 'aws-sm': [ 'accessKeyId', 'secretAccessKey', 'roleArn', 'externalId', 'granularity', 'secretNameTemplate', 'customTags', ], 'azure-kv': ['clientId', 'clientSecret', 'granularity', 'secretNameTemplate', 'customTags'], 'gcp-sm': ['projectId', 'credentials', 'granularity', 'secretNameTemplate', 'customTags'], gh: ['accessToken', 'granularity', 'secretNameTemplate'], 'vercel-project': [ 'accessToken', 'teamId', 'deploymentEnvironments', 'granularity', 'secretNameTemplate', ], }; const EXPECTED_VALUE = (key) => { switch (key) { case 'custom_tags': return { foo: `new-${key}-value` }; case 'deployment_environments': return ['production']; case 'granularity': return 'secret-key'; default: // for all string type parameters return `new-${key}-value`; } }; for (const destination of SYNC_DESTINATIONS) { const { type, maskedParams } = destination; module(`edit destination: ${type}`, function (hooks) { hooks.beforeEach(function () { this.model = this.generateModel(type); }); test('it renders destination form and PATCH updates a destination', async function (assert) { const disabledAssertions = this.model.formFields.filter((f) => f.options.editDisabled).length; const editable = EDITABLE_FIELDS[this.model.type]; assert.expect(5 + disabledAssertions + editable.length); this.server.patch(`sys/sync/destinations/${type}/${this.model.name}`, (schema, req) => { assert.ok(true, `makes request: PATCH sys/sync/destinations/${type}/${this.model.name}`); const payload = JSON.parse(req.requestBody); const payloadKeys = Object.keys(payload); const expectedKeys = editable.map((k) => decamelize(k)); assert.propEqual(payloadKeys, expectedKeys, `${type} payload only contains editable attrs`); expectedKeys.forEach((key) => { assert.deepEqual(payload[key], EXPECTED_VALUE(key), `destination: ${type} updates key: ${key}`); }); return { payload }; }); await this.renderFormComponent(); assert.dom(PAGE.title).hasTextContaining(`Edit ${this.model.name}`); for (const attr of this.model.formFields) { if (editable.includes(attr.name)) { if (maskedParams.includes(attr.name)) { // Enable inputs with sensitive values await click(PAGE.form.enableInput(attr.name)); } await PAGE.form.fillInByAttr(attr.name, `new-${decamelize(attr.name)}-value`); } else { assert.dom(PAGE.inputByAttr(attr.name)).isDisabled(`${attr.name} is disabled`); } } await click(PAGE.saveButton); const actualArgs = this.transitionStub.lastCall.args; const expectedArgs = [ 'vault.cluster.sync.secrets.destinations.destination.details', type, this.model.name, ]; assert.propEqual(actualArgs, expectedArgs, 'transitionTo called with expected args'); assert.propEqual( this.clearDatasetStub.lastCall.args, ['sync/destination'], 'Store dataset is cleared on create success' ); }); }); } });