Files
vault/ui/tests/integration/components/sync/secrets/page/destinations/create-and-edit-test.js
Angel Garbarino 66e78db425 Mask obfuscated Secret sync create/edit fields (#27348)
* wip not working on edit view

* changelog

* vercel and fix tests

* need conditional to not break all the things:

* create test coverage and add for other obfustcaed fonts, still missing one.

* Update 27348.txt

* remove meep

* comment

* test coverage
2024-06-18 14:20:22 -06:00

402 lines
16 KiB
JavaScript

/**
* 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` <Secrets::Page::Destinations::CreateAndEdit @destination={{this.model}} />`, {
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'
);
});
});
}
});