Commit 9bf7a9ba authored by Mark Florian's avatar Mark Florian

Merge branch '26777-add-aws-autocomplete' into 'master'

Resolve "Typed AWS environment variables for access keys & region"

See merge request gitlab-org/gitlab!29124
parents 1f05f317 6a9a5d77
<script>
import { uniqueId } from 'lodash';
import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui';
export default {
name: 'CiKeyField',
components: {
GlButton,
GlFormGroup,
GlFormInput,
},
model: {
prop: 'value',
event: 'input',
},
props: {
tokenList: {
type: Array,
required: true,
},
value: {
type: String,
required: true,
},
},
data() {
return {
results: [],
arrowCounter: -1,
userDismissedResults: false,
suggestionsId: uniqueId('token-suggestions-'),
};
},
computed: {
showAutocomplete() {
return this.showSuggestions ? 'off' : 'on';
},
showSuggestions() {
return this.results.length > 0;
},
},
mounted() {
document.addEventListener('click', this.handleClickOutside);
},
destroyed() {
document.removeEventListener('click', this.handleClickOutside);
},
methods: {
closeSuggestions() {
this.results = [];
this.arrowCounter = -1;
},
handleClickOutside(event) {
if (!this.$el.contains(event.target)) {
this.closeSuggestions();
}
},
onArrowDown() {
const newCount = this.arrowCounter + 1;
if (newCount >= this.results.length) {
this.arrowCounter = 0;
return;
}
this.arrowCounter = newCount;
},
onArrowUp() {
const newCount = this.arrowCounter - 1;
if (newCount < 0) {
this.arrowCounter = this.results.length - 1;
return;
}
this.arrowCounter = newCount;
},
onEnter() {
const currentToken = this.results[this.arrowCounter] || this.value;
this.selectToken(currentToken);
},
onEsc() {
if (!this.showSuggestions) {
this.$emit('input', '');
}
this.closeSuggestions();
this.userDismissedResults = true;
},
onEntry(value) {
this.$emit('input', value);
this.userDismissedResults = false;
// short circuit so that we don't false match on empty string
if (value.length < 1) {
this.closeSuggestions();
return;
}
const filteredTokens = this.tokenList.filter(token =>
token.toLowerCase().includes(value.toLowerCase()),
);
if (filteredTokens.length) {
this.openSuggestions(filteredTokens);
} else {
this.closeSuggestions();
}
},
openSuggestions(filteredResults) {
this.results = filteredResults;
},
selectToken(value) {
this.$emit('input', value);
this.closeSuggestions();
this.$emit('key-selected');
},
},
};
</script>
<template>
<div>
<div class="dropdown position-relative" role="combobox" aria-owns="token-suggestions">
<gl-form-group :label="__('Key')" label-for="ci-variable-key">
<gl-form-input
id="ci-variable-key"
:value="value"
type="text"
role="searchbox"
class="form-control pl-2 js-env-input"
:autocomplete="showAutocomplete"
aria-autocomplete="list"
aria-controls="token-suggestions"
aria-haspopup="listbox"
:aria-expanded="showSuggestions"
data-qa-selector="variable_key"
@input="onEntry"
@keydown.down="onArrowDown"
@keydown.up="onArrowUp"
@keydown.enter.prevent="onEnter"
@keydown.esc.stop="onEsc"
@keydown.tab="closeSuggestions"
/>
</gl-form-group>
<div
v-show="showSuggestions && !userDismissedResults"
id="ci-variable-dropdown"
class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width"
:class="{ 'd-block': showSuggestions }"
>
<div class="dropdown-content">
<ul :id="suggestionsId">
<li
v-for="(result, i) in results"
:key="i"
role="option"
:class="{ 'gl-bg-gray-100': i === arrowCounter }"
:aria-selected="i === arrowCounter"
>
<gl-button tabindex="-1" class="btn-transparent pl-2" @click="selectToken(result)">{{
result
}}</gl-button>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
import { __ } from '~/locale';
import { AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_SECRET_ACCESS_KEY } from '../constants';
export const awsTokens = {
[AWS_ACCESS_KEY_ID]: {
name: AWS_ACCESS_KEY_ID,
/* Checks for exactly twenty characters that match key.
Based on greps suggested by Amazon at:
https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/
*/
validation: val => /^[A-Za-z0-9]{20}$/.test(val),
invalidMessage: __('This variable does not match the expected pattern.'),
},
[AWS_DEFAULT_REGION]: {
name: AWS_DEFAULT_REGION,
},
[AWS_SECRET_ACCESS_KEY]: {
name: AWS_SECRET_ACCESS_KEY,
/* Checks for exactly forty characters that match secret.
Based on greps suggested by Amazon at:
https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/
*/
validation: val => /^[A-Za-z0-9/+=]{40}$/.test(val),
invalidMessage: __('This variable does not match the expected pattern.'),
},
};
export const awsTokenList = Object.keys(awsTokens);
<script>
import { __ } from '~/locale';
import { mapActions, mapState } from 'vuex';
import { ADD_CI_VARIABLE_MODAL_ID } from '../constants';
import CiEnvironmentsDropdown from './ci_environments_dropdown.vue';
import {
GlDeprecatedButton,
GlModal,
......@@ -14,11 +10,19 @@ import {
GlLink,
GlIcon,
} from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { ADD_CI_VARIABLE_MODAL_ID } from '../constants';
import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens';
import CiKeyField from './ci_key_field.vue';
import CiEnvironmentsDropdown from './ci_environments_dropdown.vue';
export default {
modalId: ADD_CI_VARIABLE_MODAL_ID,
components: {
CiEnvironmentsDropdown,
CiKeyField,
GlDeprecatedButton,
GlModal,
GlFormSelect,
......@@ -29,6 +33,9 @@ export default {
GlLink,
GlIcon,
},
mixins: [glFeatureFlagsMixin()],
tokens: awsTokens,
tokenList: awsTokenList,
computed: {
...mapState([
'projectId',
......@@ -41,23 +48,24 @@ export default {
'selectedEnvironment',
]),
canSubmit() {
if (this.variableData.masked && this.maskedState === false) {
return false;
}
return this.variableData.key !== '' && this.variableData.secret_value !== '';
return (
this.variableValidationState &&
this.variableData.key !== '' &&
this.variableData.secret_value !== ''
);
},
canMask() {
const regex = RegExp(this.maskableRegex);
return regex.test(this.variableData.secret_value);
},
displayMaskedError() {
return !this.canMask && this.variableData.masked && this.variableData.secret_value !== '';
return !this.canMask && this.variableData.masked;
},
maskedState() {
if (this.displayMaskedError) {
return false;
}
return null;
return true;
},
variableData() {
return this.variableBeingEdited || this.variable;
......@@ -66,7 +74,41 @@ export default {
return this.variableBeingEdited ? __('Update variable') : __('Add variable');
},
maskedFeedback() {
return __('This variable can not be masked');
return this.displayMaskedError ? __('This variable can not be masked.') : '';
},
tokenValidationFeedback() {
const tokenSpecificFeedback = this.$options.tokens?.[this.variableData.key]?.invalidMessage;
if (!this.tokenValidationState && tokenSpecificFeedback) {
return tokenSpecificFeedback;
}
return '';
},
tokenValidationState() {
// If the feature flag is off, do not validate. Remove when flag is removed.
if (!this.glFeatures.ciKeyAutocomplete) {
return true;
}
const validator = this.$options.tokens?.[this.variableData.key]?.validation;
if (validator) {
return validator(this.variableData.secret_value);
}
return true;
},
variableValidationFeedback() {
return `${this.tokenValidationFeedback} ${this.maskedFeedback}`;
},
variableValidationState() {
if (
this.variableData.secret_value === '' ||
(this.tokenValidationState && this.maskedState)
) {
return true;
}
return false;
},
},
methods: {
......@@ -82,14 +124,13 @@ export default {
'resetSelectedEnvironment',
'setSelectedEnvironment',
]),
updateOrAddVariable() {
if (this.variableBeingEdited) {
this.updateVariable(this.variableBeingEdited);
} else {
this.addVariable();
}
deleteVarAndClose() {
this.deleteVariable(this.variableBeingEdited);
this.hideModal();
},
hideModal() {
this.$refs.modal.hide();
},
resetModalHandler() {
if (this.variableBeingEdited) {
this.resetEditing();
......@@ -98,11 +139,12 @@ export default {
}
this.resetSelectedEnvironment();
},
hideModal() {
this.$refs.modal.hide();
},
deleteVarAndClose() {
this.deleteVariable(this.variableBeingEdited);
updateOrAddVariable() {
if (this.variableBeingEdited) {
this.updateVariable(this.variableBeingEdited);
} else {
this.addVariable();
}
this.hideModal();
},
},
......@@ -119,7 +161,13 @@ export default {
@hidden="resetModalHandler"
>
<form>
<gl-form-group :label="__('Key')" label-for="ci-variable-key">
<ci-key-field
v-if="glFeatures.ciKeyAutocomplete"
v-model="variableData.key"
:token-list="$options.tokenList"
/>
<gl-form-group v-else :label="__('Key')" label-for="ci-variable-key">
<gl-form-input
id="ci-variable-key"
v-model="variableData.key"
......@@ -130,12 +178,14 @@ export default {
<gl-form-group
:label="__('Value')"
label-for="ci-variable-value"
:state="maskedState"
:invalid-feedback="maskedFeedback"
:state="variableValidationState"
:invalid-feedback="variableValidationFeedback"
>
<gl-form-textarea
id="ci-variable-value"
ref="valueField"
v-model="variableData.secret_value"
:state="variableValidationState"
rows="3"
max-rows="6"
data-qa-selector="ci_variable_value_field"
......
......@@ -14,3 +14,8 @@ export const types = {
fileType: 'file',
allEnvironmentsType: '*',
};
// AWS TOKEN CONSTANTS
export const AWS_ACCESS_KEY_ID = 'AWS_ACCESS_KEY_ID';
export const AWS_DEFAULT_REGION = 'AWS_DEFAULT_REGION';
export const AWS_SECRET_ACCESS_KEY = 'AWS_SECRET_ACCESS_KEY';
......@@ -8,6 +8,7 @@ module Projects
before_action do
push_frontend_feature_flag(:new_variables_ui, @project)
push_frontend_feature_flag(:ajax_new_deploy_token, @project)
push_frontend_feature_flag(:ci_key_autocomplete, default_enabled: true)
end
def show
......
---
title: Add typed AWS environment variables for access keys & region
merge_request: 29124
author:
type: added
......@@ -21206,7 +21206,10 @@ msgstr ""
msgid "This user will be the author of all events in the activity feed that are the result of an update, like new branches being created or new commits being pushed to existing branches. Upon creation or when reassigning you can only assign yourself to be the mirror user."
msgstr ""
msgid "This variable can not be masked"
msgid "This variable can not be masked."
msgstr ""
msgid "This variable does not match the expected pattern."
msgstr ""
msgid "This will help us personalize your onboarding experience."
......
import { mount } from '@vue/test-utils';
import { GlButton, GlFormInput } from '@gitlab/ui';
import { AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION } from '~/ci_variable_list/constants';
import CiKeyField from '~/ci_variable_list/components/ci_key_field.vue';
import {
awsTokens,
awsTokenList,
} from '~/ci_variable_list/components/ci_variable_autocomplete_tokens';
const doTimes = (num, fn) => {
for (let i = 0; i < num; i += 1) {
fn();
}
};
describe('Ci Key field', () => {
let wrapper;
const createComponent = () => {
wrapper = mount({
data() {
return {
inputVal: '',
tokens: awsTokenList,
};
},
components: { CiKeyField },
template: `
<div>
<ci-key-field
v-model="inputVal"
:token-list="tokens"
/>
</div>
`,
});
};
const findDropdown = () => wrapper.find('#ci-variable-dropdown');
const findDropdownOptions = () => wrapper.findAll(GlButton).wrappers.map(item => item.text());
const findInput = () => wrapper.find(GlFormInput);
const findInputValue = () => findInput().element.value;
const setInput = val => findInput().setValue(val);
const clickDown = () => findInput().trigger('keydown.down');
afterEach(() => {
wrapper.destroy();
});
describe('match and filter functionality', () => {
beforeEach(() => {
createComponent();
});
it('is closed when the input is empty', () => {
expect(findInput().isVisible()).toBe(true);
expect(findInputValue()).toBe('');
expect(findDropdown().isVisible()).toBe(false);
});
it('is open when the input text matches a token', () => {
setInput('AWS');
return wrapper.vm.$nextTick().then(() => {
expect(findDropdown().isVisible()).toBe(true);
});
});
it('shows partial matches at string start', () => {
setInput('AWS');
return wrapper.vm.$nextTick().then(() => {
expect(findDropdown().isVisible()).toBe(true);
expect(findDropdownOptions()).toEqual(awsTokenList);
});
});
it('shows partial matches mid-string', () => {
setInput('D');
return wrapper.vm.$nextTick().then(() => {
expect(findDropdown().isVisible()).toBe(true);
expect(findDropdownOptions()).toEqual([
awsTokens[AWS_ACCESS_KEY_ID].name,
awsTokens[AWS_DEFAULT_REGION].name,
]);
});
});
it('is closed when the text does not match', () => {
setInput('elephant');
return wrapper.vm.$nextTick().then(() => {
expect(findDropdown().isVisible()).toBe(false);
});
});
});
describe('keyboard navigation in dropdown', () => {
beforeEach(() => {
createComponent();
});
describe('on down arrow + enter', () => {
it('selects the next item in the list and closes the dropdown', () => {
setInput('AWS');
return wrapper.vm
.$nextTick()
.then(() => {
findInput().trigger('keydown.down');
findInput().trigger('keydown.enter');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findInputValue()).toBe(awsTokenList[0]);
});
});
it('loops to the top when it reaches the bottom', () => {
setInput('AWS');
return wrapper.vm
.$nextTick()
.then(() => {
doTimes(findDropdownOptions().length + 1, clickDown);
findInput().trigger('keydown.enter');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findInputValue()).toBe(awsTokenList[0]);
});
});
});
describe('on up arrow + enter', () => {
it('selects the previous item in the list and closes the dropdown', () => {
setInput('AWS');
return wrapper.vm
.$nextTick()
.then(() => {
doTimes(3, clickDown);
findInput().trigger('keydown.up');
findInput().trigger('keydown.enter');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findInputValue()).toBe(awsTokenList[1]);
});
});
it('loops to the bottom when it reaches the top', () => {
setInput('AWS');
return wrapper.vm
.$nextTick()
.then(() => {
findInput().trigger('keydown.down');
findInput().trigger('keydown.up');
findInput().trigger('keydown.enter');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findInputValue()).toBe(awsTokenList[awsTokenList.length - 1]);
});
});
});
describe('on enter with no item highlighted', () => {
it('does not select any item and closes the dropdown', () => {
setInput('AWS');
return wrapper.vm
.$nextTick()
.then(() => {
findInput().trigger('keydown.enter');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findInputValue()).toBe('AWS');
});
});
});
describe('on click', () => {
it('selects the clicked item regardless of arrow highlight', () => {
setInput('AWS');
return wrapper.vm
.$nextTick()
.then(() => {
wrapper.find(GlButton).trigger('click');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findInputValue()).toBe(awsTokenList[0]);
});
});
});
describe('on tab', () => {
it('selects entered text, closes dropdown', () => {
setInput('AWS');
return wrapper.vm
.$nextTick()
.then(() => {
findInput().trigger('keydown.tab');
doTimes(2, clickDown);
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findInputValue()).toBe('AWS');
expect(findDropdown().isVisible()).toBe(false);
});
});
});
describe('on esc', () => {
describe('when dropdown is open', () => {
it('closes dropdown and does not select anything', () => {
setInput('AWS');
return wrapper.vm
.$nextTick()
.then(() => {
findInput().trigger('keydown.esc');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findInputValue()).toBe('AWS');
expect(findDropdown().isVisible()).toBe(false);
});
});
});
describe('when dropdown is closed', () => {
it('clears the input field', () => {
setInput('elephant');
return wrapper.vm
.$nextTick()
.then(() => {
expect(findDropdown().isVisible()).toBe(false);
findInput().trigger('keydown.esc');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findInputValue()).toBe('');
});
});
});
});
});
});
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
import { GlDeprecatedButton } from '@gitlab/ui';
import { AWS_ACCESS_KEY_ID } from '~/ci_variable_list/constants';
import CiVariableModal from '~/ci_variable_list/components/ci_variable_modal.vue';
import CiKeyField from '~/ci_variable_list/components/ci_key_field.vue';
import { awsTokens } from '~/ci_variable_list/components/ci_variable_autocomplete_tokens';
import createStore from '~/ci_variable_list/store';
import mockData from '../services/mock_data';
import ModalStub from '../stubs';
......@@ -13,14 +16,17 @@ describe('Ci variable modal', () => {
let wrapper;
let store;
const createComponent = () => {
const createComponent = (method, options = {}) => {
store = createStore();
wrapper = shallowMount(CiVariableModal, {
wrapper = method(CiVariableModal, {
attachToDocument: true,
provide: { glFeatures: { ciKeyAutocomplete: true } },
stubs: {
GlModal: ModalStub,
},
localVue,
store,
...options,
});
};
......@@ -34,22 +40,46 @@ describe('Ci variable modal', () => {
.findAll(GlDeprecatedButton)
.at(1);
beforeEach(() => {
createComponent();
jest.spyOn(store, 'dispatch').mockImplementation();
});
afterEach(() => {
wrapper.destroy();
});
it('button is disabled when no key/value pair are present', () => {
expect(addOrUpdateButton(1).attributes('disabled')).toBeTruthy();
describe('Feature flag', () => {
describe('when off', () => {
beforeEach(() => {
createComponent(shallowMount, { provide: { glFeatures: { ciKeyAutocomplete: false } } });
});
it('does not render the autocomplete dropdown', () => {
expect(wrapper.contains(CiKeyField)).toBe(false);
});
});
describe('when on', () => {
beforeEach(() => {
createComponent(shallowMount);
});
it('renders the autocomplete dropdown', () => {
expect(wrapper.find(CiKeyField).exists()).toBe(true);
});
});
});
describe('Basic interactions', () => {
beforeEach(() => {
createComponent(shallowMount);
});
it('button is disabled when no key/value pair are present', () => {
expect(addOrUpdateButton(1).attributes('disabled')).toBeTruthy();
});
});
describe('Adding a new variable', () => {
beforeEach(() => {
const [variable] = mockData.mockVariables;
createComponent(shallowMount);
jest.spyOn(store, 'dispatch').mockImplementation();
store.state.variable = variable;
});
......@@ -71,6 +101,8 @@ describe('Ci variable modal', () => {
describe('Editing a variable', () => {
beforeEach(() => {
const [variable] = mockData.mockVariables;
createComponent(shallowMount);
jest.spyOn(store, 'dispatch').mockImplementation();
store.state.variableBeingEdited = variable;
});
......@@ -96,4 +128,105 @@ describe('Ci variable modal', () => {
expect(store.dispatch).toHaveBeenCalledWith('deleteVariable', mockData.mockVariables[0]);
});
});
describe('Validations', () => {
const maskError = 'This variable can not be masked.';
describe('when the key state is invalid', () => {
beforeEach(() => {
const [variable] = mockData.mockVariables;
const invalidKeyVariable = {
...variable,
key: AWS_ACCESS_KEY_ID,
value: 'AKIAIOSFODNN7EXAMPLEjdhy',
secret_value: 'AKIAIOSFODNN7EXAMPLEjdhy',
};
createComponent(mount);
store.state.variable = invalidKeyVariable;
});
it('disables the submit button', () => {
expect(addOrUpdateButton(1).attributes('disabled')).toBeTruthy();
});
it('shows the correct error text', () => {
const errorText = awsTokens[AWS_ACCESS_KEY_ID].invalidMessage;
expect(findModal().text()).toContain(errorText);
});
});
describe('when the mask state is invalid', () => {
beforeEach(() => {
const [variable] = mockData.mockVariables;
const invalidMaskVariable = {
...variable,
key: 'qs',
value: 'd:;',
secret_value: 'd:;',
masked: true,
};
createComponent(mount);
store.state.variable = invalidMaskVariable;
});
it('disables the submit button', () => {
expect(addOrUpdateButton(1).attributes('disabled')).toBeTruthy();
});
it('shows the correct error text', () => {
expect(findModal().text()).toContain(maskError);
});
});
describe('when the mask and key states are invalid', () => {
beforeEach(() => {
const [variable] = mockData.mockVariables;
const invalidMaskandKeyVariable = {
...variable,
key: AWS_ACCESS_KEY_ID,
value: 'AKIAIOSFODNN7EXAMPLEjdhyd:;',
secret_value: 'AKIAIOSFODNN7EXAMPLEjdhyd:;',
masked: true,
};
createComponent(mount);
store.state.variable = invalidMaskandKeyVariable;
});
it('disables the submit button', () => {
expect(addOrUpdateButton(1).attributes('disabled')).toBeTruthy();
});
it('shows the correct error text', () => {
const errorText = awsTokens[AWS_ACCESS_KEY_ID].invalidMessage;
expect(findModal().text()).toContain(maskError);
expect(findModal().text()).toContain(errorText);
});
});
describe('when both states are valid', () => {
beforeEach(() => {
const [variable] = mockData.mockVariables;
const validMaskandKeyVariable = {
...variable,
key: AWS_ACCESS_KEY_ID,
value: 'AKIAIOSFODNN7EXAMPLE',
secret_value: 'AKIAIOSFODNN7EXAMPLE',
masked: true,
};
createComponent(mount);
store.state.variable = validMaskandKeyVariable;
store.state.maskableRegex = /^[a-zA-Z0-9_+=/@:-]{8,}$/;
});
it('does not disable the submit button', () => {
expect(addOrUpdateButton(1).attributes('disabled')).toBeFalsy();
});
it('shows no error text', () => {
const errorText = awsTokens[AWS_ACCESS_KEY_ID].invalidMessage;
expect(findModal().text()).not.toContain(maskError);
expect(findModal().text()).not.toContain(errorText);
});
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment