diff options
22 files changed, 440 insertions, 253 deletions
diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue index c52e9f5c264..8a481db5cb6 100644 --- a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue +++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue @@ -13,6 +13,12 @@ import { s__, __ } from '~/locale'; // data format is defined and will be the same as mocked (maybe with some minor changes) // feature rollout plan - https://gitlab.com/gitlab-org/gitlab/-/issues/262707#note_442529171 import gitlabFieldsMock from './mocks/gitlabFields.json'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { + getMappingData, + getPayloadFields, + transformForSave, +} from '../utils/mapping_transformations'; export const i18n = { columns: { @@ -46,12 +52,12 @@ export default { }, }, props: { - payloadFields: { + parsedPayload: { type: Array, required: false, default: () => [], }, - mapping: { + savedMapping: { type: Array, required: false, default: () => [], @@ -63,27 +69,11 @@ export default { }; }, computed: { + payloadFields() { + return getPayloadFields(this.parsedPayload); + }, mappingData() { - return this.gitlabFields.map((gitlabField) => { - const mappingFields = this.payloadFields.filter(({ type }) => - type.some((t) => gitlabField.compatibleTypes.includes(t)), - ); - - const foundMapping = this.mapping.find( - ({ alertFieldName }) => alertFieldName === gitlabField.name, - ); - - const { fallbackAlertPaths, payloadAlertPaths } = foundMapping || {}; - - return { - mapping: payloadAlertPaths, - fallback: fallbackAlertPaths, - searchTerm: '', - fallbackSearchTerm: '', - mappingFields, - ...gitlabField, - }; - }); + return getMappingData(this.gitlabFields, this.payloadFields, this.savedMapping); }, }, methods: { @@ -91,6 +81,7 @@ export default { const fieldIndex = this.gitlabFields.findIndex((field) => field.name === gitlabKey); const updatedField = { ...this.gitlabFields[fieldIndex], ...{ [valueKey]: mappingKey } }; Vue.set(this.gitlabFields, fieldIndex, updatedField); + this.$emit('onMappingUpdate', transformForSave(this.mappingData)); }, setSearchTerm(search = '', searchFieldKey, gitlabKey) { const fieldIndex = this.gitlabFields.findIndex((field) => field.name === gitlabKey); @@ -99,7 +90,6 @@ export default { }, filterFields(searchTerm = '', fields) { const search = searchTerm.toLowerCase(); - return fields.filter((field) => field.label.toLowerCase().includes(search)); }, isSelected(fieldValue, mapping) { @@ -112,7 +102,9 @@ export default { ); }, getFieldValue({ label, type }) { - return `${label} (${type.join(__(' or '))})`; + const types = type.map((t) => capitalizeFirstCharacter(t.toLowerCase())).join(__(' or ')); + + return `${label} (${types})`; }, noResults(searchTerm, fields) { return !this.filterFields(searchTerm, fields).length; diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue index 1ae7f826ce6..3280e0424aa 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -152,6 +152,7 @@ export default { }, resetSamplePayloadConfirmed: false, customMapping: null, + mapping: [], parsingPayload: false, currentIntegration: null, }; @@ -199,10 +200,10 @@ export default { this.selectedIntegration === typeSet.http ); }, - mappingBuilderFields() { + parsedSamplePayload() { return this.customMapping?.samplePayload?.payloadAlerFields?.nodes; }, - mappingBuilderMapping() { + savedMapping() { return this.customMapping?.storedMapping?.nodes; }, hasSamplePayload() { @@ -255,9 +256,20 @@ export default { }, submit() { const { name, apiUrl } = this.integrationForm; + const customMappingVariables = this.glFeatures.multipleHttpIntegrationsCustomMapping + ? { + payloadAttributeMappings: this.mapping, + payloadExample: this.integrationTestPayload.json, + } + : {}; + const variables = this.selectedIntegration === typeSet.http - ? { name, active: this.active } + ? { + name, + active: this.active, + ...customMappingVariables, + } : { apiUrl, active: this.active }; const integrationPayload = { type: this.selectedIntegration, variables }; @@ -336,6 +348,9 @@ export default { this.integrationTestPayload.json = res?.samplePayload.body; }); }, + updateMapping(mapping) { + this.mapping = mapping; + }, }, }; </script> @@ -541,8 +556,9 @@ export default { > <span>{{ $options.i18n.integrationFormSteps.step5.intro }}</span> <mapping-builder - :payload-fields="mappingBuilderFields" - :mapping="mappingBuilderMapping" + :parsed-payload="parsedSamplePayload" + :saved-mapping="savedMapping" + @onMappingUpdate="updateMapping" /> </gl-form-group> </div> diff --git a/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json b/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json index ac559a30eda..e4d0e92a6f8 100644 --- a/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json +++ b/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json @@ -1,112 +1,123 @@ [ { - "name": "title", + "name": "TITLE", "label": "Title", "type": [ - "String" + "STRING" ], "compatibleTypes": [ - "String", - "Number", - "DateTime" + "STRING", + "NUMBER", + "DATETIME" ], "numberOfFallbacks": 1 }, { - "name": "description", + "name": "DESCRIPTION", "label": "Description", "type": [ - "String" + "STRING" ], "compatibleTypes": [ - "String", - "Number", - "DateTime" + "STRING", + "NUMBER", + "DATETIME" ] }, { - "name": "startTime", + "name": "START_TIME", "label": "Start time", "type": [ - "DateTime" + "DATETIME" ], "compatibleTypes": [ - "Number", - "DateTime" + "NUMBER", + "DATETIME" ] }, { - "name": "service", + "name": "END_TIME", + "label": "End time", + "type": [ + "DATETIME" + ], + "compatibleTypes": [ + "NUMBER", + "DATETIME" + ] + }, + { + "name": "SERVICE", "label": "Service", "type": [ - "String" + "STRING" ], "compatibleTypes": [ - "String", - "Number", - "DateTime" + "STRING", + "NUMBER", + "DATETIME" ] }, { - "name": "monitoringTool", + "name": "MONITORING_TOOL", "label": "Monitoring tool", "type": [ - "String" + "STRING" ], "compatibleTypes": [ - "String", - "Number", - "DateTime" + "STRING", + "NUMBER", + "DATETIME" ] }, { - "name": "hosts", + "name": "HOSTS", "label": "Hosts", "type": [ - "String", - "Array" + "STRING", + "ARRAY" ], "compatibleTypes": [ - "String", - "Array", - "Number", - "DateTime" + "STRING", + "ARRAY", + "NUMBER", + "DATETIME" ] }, { - "name": "severity", + "name": "SEVERITY", "label": "Severity", "type": [ - "String" + "STRING" ], "compatibleTypes": [ - "String", - "Number", - "DateTime" + "STRING", + "NUMBER", + "DATETIME" ] }, { - "name": "fingerprint", + "name": "FINGERPRINT", "label": "Fingerprint", "type": [ - "String" + "STRING" ], "compatibleTypes": [ - "String", - "Number", - "DateTime" + "STRING", + "NUMBER", + "DATETIME" ] }, { - "name": "environment", + "name": "GITLAB_ENVIRONMENT_NAME", "label": "Environment", "type": [ - "String" + "STRING" ], "compatibleTypes": [ - "String", - "Number", - "DateTime" + "STRING", + "NUMBER", + "DATETIME" ] } ] diff --git a/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json b/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json index 5326678155d..c1de0d6f0e0 100644 --- a/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json +++ b/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json @@ -4,95 +4,69 @@ "payloadAlerFields": { "nodes": [ { - "name": "dashboardId", + "path": ["dashboardId"], "label": "Dashboard Id", - "type": [ - "Number" - ] + "type": "STRING" }, { - "name": "evalMatches", + "path": ["evalMatches"], "label": "Eval Matches", - "type": [ - "Array" - ] + "type": "ARRAY" }, { - "name": "createdAt", + "path": ["createdAt"], "label": "Created At", - "type": [ - "DateTime" - ] + "type": "DATETIME" }, { - "name": "imageUrl", + "path": ["imageUrl"], "label": "Image Url", - "type": [ - "String" - ] + "type": "STRING" }, { - "name": "message", + "path": ["message"], "label": "Message", - "type": [ - "String" - ] + "type": "STRING" }, { - "name": "orgId", + "path": ["orgId"], "label": "Org Id", - "type": [ - "Number" - ] + "type": "STRING" }, { - "name": "panelId", + "path": ["panelId"], "label": "Panel Id", - "type": [ - "String" - ] + "type": "STRING" }, { - "name": "ruleId", + "path": ["ruleId"], "label": "Rule Id", - "type": [ - "Number" - ] + "type": "STRING" }, { - "name": "ruleName", + "path": ["ruleName"], "label": "Rule Name", - "type": [ - "String" - ] + "type": "STRING" }, { - "name": "ruleUrl", + "path": ["ruleUrl"], "label": "Rule Url", - "type": [ - "String" - ] + "type": "STRING" }, { - "name": "state", + "path": ["state"], "label": "State", - "type": [ - "String" - ] + "type": "STRING" }, { - "name": "title", + "path": ["title"], "label": "Title", - "type": [ - "String" - ] + "type": "STRING" }, { - "name": "tags", + "path": ["tags", "tag"], "label": "Tags", - "type": [ - "Object" - ] + "type": "STRING" } ] } diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql index d1dacbad40a..f3fc10b4bd4 100644 --- a/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql @@ -1,7 +1,21 @@ #import "../fragments/integration_item.fragment.graphql" -mutation createHttpIntegration($projectPath: ID!, $name: String!, $active: Boolean!) { - httpIntegrationCreate(input: { projectPath: $projectPath, name: $name, active: $active }) { +mutation createHttpIntegration( + $projectPath: ID! + $name: String! + $active: Boolean! + $payloadExample: JsonString + $payloadAttributeMappings: [AlertManagementPayloadAlertFieldInput!] +) { + httpIntegrationCreate( + input: { + projectPath: $projectPath + name: $name + active: $active + payloadExample: $payloadExample + payloadAttributeMappings: $payloadAttributeMappings + } + ) { errors integration { ...IntegrationItem diff --git a/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js b/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js new file mode 100644 index 00000000000..a7e43c93fbf --- /dev/null +++ b/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js @@ -0,0 +1,63 @@ +/** + * Given data for GitLab alert fields, parsed payload fields data and previously stored mapping (if any) + * creates an object in a form convenient to build UI && interact with it + * @param {Object} gitlabFields - structure describing GitLab alert fields + * @param {Object} payloadFields - parsed from sample JSON sample alert fields + * @param {Object} savedMapping - GitLab fields to parsed fields mapping + * + * @return {Object} mapping data for UI mapping builder + */ +export const getMappingData = (gitlabFields, payloadFields, savedMapping) => { + return gitlabFields.map((gitlabField) => { + // find fields from payload that match gitlab alert field by type + const mappingFields = payloadFields.filter(({ type }) => + gitlabField.compatibleTypes.includes(type), + ); + + // find the mapping that was previously stored + const foundMapping = savedMapping.find(({ fieldName }) => fieldName === gitlabField.name); + + const { fallbackAlertPaths, payloadAlertPaths } = foundMapping || {}; + + return { + mapping: payloadAlertPaths, + fallback: fallbackAlertPaths, + searchTerm: '', + fallbackSearchTerm: '', + mappingFields, + ...gitlabField, + }; + }); +}; + +/** + * Based on mapping data configured by the user creates an object in a format suitable for save on BE + * @param {Object} mappingData - structure describing mapping between GitLab fields and parsed payload fields + * + * @return {Object} mapping data to send to BE + */ +export const transformForSave = (mappingData) => { + return mappingData.reduce((acc, field) => { + const mapped = field.mappingFields.find(({ name }) => name === field.mapping); + if (mapped) { + const { path, type, label } = mapped; + acc.push({ + fieldName: field.name, + path, + type, + label, + }); + } + return acc; + }, []); +}; + +/** + * Adds `name` prop to each provided by BE parsed payload field + * @param {Object} payload - parsed sample payload + * + * @return {Object} same as input with an extra `name` property which basically serves as a key to make a match + */ +export const getPayloadFields = (payload) => { + return payload.map((field) => ({ ...field, name: field.path.join('_') })); +}; diff --git a/doc/api/oauth2.md b/doc/api/oauth2.md index a80a97890ba..9f20fd540a4 100644 --- a/doc/api/oauth2.md +++ b/doc/api/oauth2.md @@ -89,7 +89,7 @@ Before starting the flow, generate the `STATE`, the `CODE_VERIFIER` and the `COD `/oauth/authorize` page with the following query parameters: ```plaintext - https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code&state=YOUR_UNIQUE_STATE_HASH&scope=REQUESTED_SCOPES&code_challenge=CODE_CHALLENGE&code_challenge_method=S256 + https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code&state=STATE&scope=REQUESTED_SCOPES&code_challenge=CODE_CHALLENGE&code_challenge_method=S256 ``` This page asks the user to approve the request from the app to access their @@ -100,7 +100,7 @@ Before starting the flow, generate the `STATE`, the `CODE_VERIFIER` and the `COD The redirect includes the authorization `code`, for example: ```plaintext - https://example.com/oauth/redirect?code=1234567890&state=YOUR_UNIQUE_STATE_HASH + https://example.com/oauth/redirect?code=1234567890&state=STATE ``` 1. With the authorization `code` returned from the previous request (denoted as @@ -139,29 +139,31 @@ detailed flow description. The authorization code flow is essentially the same as [authorization code flow with PKCE](#authorization-code-with-proof-key-for-code-exchange-pkce), +Before starting the flow, generate the `STATE`. It is a value that can't be predicted +used by the client to maintain state between the request and callback. It should also +be used as a CSRF token. + 1. Request authorization code. To do that, you should redirect the user to the - `/oauth/authorize` endpoint with the following GET parameters: + `/oauth/authorize` page with the following query parameters: ```plaintext https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code&state=STATE&scope=REQUESTED_SCOPES ``` - This will ask the user to approve the applications access to their account - based on the scopes specified in `REQUESTED_SCOPES` and then redirect back to - the `REDIRECT_URI` you provided. The [scope parameter](https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes#requesting-particular-scopes) - is a space separated list of scopes you want to have access to (e.g. `scope=read_user+profile` - would request `read_user` and `profile` scopes). The redirect will - include the GET `code` parameter, for example: + This page asks the user to approve the request from the app to access their + account based on the scopes specified in `REQUESTED_SCOPES`. The user is then + redirected back to the specified `REDIRECT_URI`. The [scope parameter](https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes#requesting-particular-scopes) + is a space separated list of scopes associated with the user. + For example,`scope=read_user+profile` requests the `read_user` and `profile` scopes. + The redirect includes the authorization `code`, for example: ```plaintext https://example.com/oauth/redirect?code=1234567890&state=STATE ``` - You should then use `code` to request an access token. - -1. After you have the authorization code you can request an `access_token` using the - code. You can do that by using any HTTP client. In the following example, - we are using Ruby's `rest-client`: +1. With the authorization `code` returned from the previous request (shown as + `RETURNED_CODE` in the following example), you can request an `access_token`, with + any HTTP client. The following example uses Ruby's `rest-client`: ```ruby parameters = 'client_id=APP_ID&client_secret=APP_SECRET&code=RETURNED_CODE&grant_type=authorization_code&redirect_uri=REDIRECT_URI' diff --git a/doc/development/integrations/secure.md b/doc/development/integrations/secure.md index fb9d894d203..1d5aced5869 100644 --- a/doc/development/integrations/secure.md +++ b/doc/development/integrations/secure.md @@ -260,6 +260,8 @@ When executing command lines, scanners should use the `debug` level to log the c For instance, the [bundler-audit](https://gitlab.com/gitlab-org/security-products/analyzers/bundler-audit) scanner uses the `debug` level to log the command line `bundle audit check --quiet`, and what `bundle audit` writes to the standard output. +If the command line fails, then it should be logged with the `error` log level; +this makes it possible to debug the problem without having to change the log level to `debug` and rerun the scanning job. #### common logutil package diff --git a/doc/operations/incident_management/alert_notifications.md b/doc/operations/incident_management/alert_notifications.md new file mode 100644 index 00000000000..6f3b329572b --- /dev/null +++ b/doc/operations/incident_management/alert_notifications.md @@ -0,0 +1,8 @@ +--- +redirect_to: 'paging.md' +--- + +This document was moved to [another location](paging.md). + +<!-- This redirect file can be deleted after <2022-01-21>. --> +<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page --> diff --git a/qa/qa/specs/features/browser_ui/3_create/snippet/add_file_to_snippet_spec.rb b/qa/qa/specs/features/browser_ui/3_create/snippet/add_file_to_snippet_spec.rb index 469335db5ab..604c98ca21e 100644 --- a/qa/qa/specs/features/browser_ui/3_create/snippet/add_file_to_snippet_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/snippet/add_file_to_snippet_spec.rb @@ -2,7 +2,7 @@ module QA RSpec.describe 'Create' do - describe 'Multiple file snippet' do + describe 'Multiple file snippet', quarantine: { only: { pipeline: :master }, issue: 'https://gitlab.com/gitlab-org/gitaly/-/issues/3143', type: :bug } do let(:personal_snippet) do Resource::Snippet.fabricate_via_api! do |snippet| snippet.title = 'Personal snippet to add file to' diff --git a/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_personal_snippet_spec.rb b/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_personal_snippet_spec.rb index a21c5d58aad..2940b2067a1 100644 --- a/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_personal_snippet_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_personal_snippet_spec.rb @@ -2,7 +2,7 @@ module QA RSpec.describe 'Create' do - describe 'Version control for personal snippets' do + describe 'Version control for personal snippets', quarantine: { only: { pipeline: :master }, issue: 'https://gitlab.com/gitlab-org/gitaly/-/issues/3143', type: :bug } do let(:new_file) { 'new_snippet_file' } let(:changed_content) { 'changes' } let(:commit_message) { 'Changes to snippets' } diff --git a/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_project_snippet_spec.rb b/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_project_snippet_spec.rb index 4ce6c3fdcd3..b91424d5b65 100644 --- a/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_project_snippet_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_project_snippet_spec.rb @@ -2,7 +2,7 @@ module QA RSpec.describe 'Create' do - describe 'Version control for project snippets' do + describe 'Version control for project snippets', quarantine: { only: { pipeline: :master }, issue: 'https://gitlab.com/gitlab-org/gitaly/-/issues/3143', type: :bug } do let(:new_file) { 'new_snippet_file' } let(:changed_content) { 'changes' } let(:commit_message) { 'Changes to snippets' } diff --git a/qa/qa/specs/features/browser_ui/3_create/snippet/delete_file_from_snippet_spec.rb b/qa/qa/specs/features/browser_ui/3_create/snippet/delete_file_from_snippet_spec.rb index 8002e95cf0d..5c0983dabb6 100644 --- a/qa/qa/specs/features/browser_ui/3_create/snippet/delete_file_from_snippet_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/snippet/delete_file_from_snippet_spec.rb @@ -2,7 +2,7 @@ module QA RSpec.describe 'Create' do - describe 'Multiple file snippet' do + describe 'Multiple file snippet', quarantine: { only: { pipeline: :master }, issue: 'https://gitlab.com/gitlab-org/gitaly/-/issues/3143', type: :bug } do let(:personal_snippet) do Resource::Snippet.fabricate_via_api! do |snippet| snippet.title = 'Personal snippet to delete file from' diff --git a/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_spec.js.snap b/spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap index ef68a6a2c32..ef68a6a2c32 100644 --- a/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_spec.js.snap +++ b/spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap diff --git a/spec/frontend/alerts_settings/alert_mapping_builder_spec.js b/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js index 5d48ff02e35..92ea8b2f33c 100644 --- a/spec/frontend/alerts_settings/alert_mapping_builder_spec.js +++ b/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js @@ -3,6 +3,8 @@ import { shallowMount } from '@vue/test-utils'; import AlertMappingBuilder, { i18n } from '~/alerts_settings/components/alert_mapping_builder.vue'; import gitlabFields from '~/alerts_settings/components/mocks/gitlabFields.json'; import parsedMapping from '~/alerts_settings/components/mocks/parsedMapping.json'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import * as transformationUtils from '~/alerts_settings/utils/mapping_transformations'; describe('AlertMappingBuilder', () => { let wrapper; @@ -10,8 +12,8 @@ describe('AlertMappingBuilder', () => { function mountComponent() { wrapper = shallowMount(AlertMappingBuilder, { propsData: { - payloadFields: parsedMapping.samplePayload.payloadAlerFields.nodes, - mapping: parsedMapping.storedMapping.nodes, + parsedPayload: parsedMapping.samplePayload.payloadAlerFields.nodes, + savedMapping: parsedMapping.storedMapping.nodes, }, }); } @@ -44,7 +46,8 @@ describe('AlertMappingBuilder', () => { it('renders disabled form input for each mapped field', () => { gitlabFields.forEach((field, index) => { const input = findColumnInRow(index + 1, 0).find(GlFormInput); - expect(input.attributes('value')).toBe(`${field.label} (${field.type.join(' or ')})`); + const types = field.type.map((t) => capitalizeFirstCharacter(t.toLowerCase())).join(' or '); + expect(input.attributes('value')).toBe(`${field.label} (${types})`); expect(input.attributes('disabled')).toBe(''); }); }); @@ -59,16 +62,14 @@ describe('AlertMappingBuilder', () => { it('renders mapping dropdown for each field', () => { gitlabFields.forEach(({ compatibleTypes }, index) => { const dropdown = findColumnInRow(index + 1, 2).find(GlDropdown); - const searchBox = dropdown.find(GlSearchBoxByType); - const dropdownItems = dropdown.findAll(GlDropdownItem); + const searchBox = dropdown.findComponent(GlSearchBoxByType); + const dropdownItems = dropdown.findAllComponents(GlDropdownItem); const { nodes } = parsedMapping.samplePayload.payloadAlerFields; - const numberOfMappingOptions = nodes.filter(({ type }) => - type.some((t) => compatibleTypes.includes(t)), - ); + const mappingOptions = nodes.filter(({ type }) => compatibleTypes.includes(type)); expect(dropdown.exists()).toBe(true); expect(searchBox.exists()).toBe(true); - expect(dropdownItems).toHaveLength(numberOfMappingOptions.length); + expect(dropdownItems).toHaveLength(mappingOptions.length); }); }); @@ -78,16 +79,23 @@ describe('AlertMappingBuilder', () => { expect(dropdown.exists()).toBe(Boolean(numberOfFallbacks)); if (numberOfFallbacks) { - const searchBox = dropdown.find(GlSearchBoxByType); - const dropdownItems = dropdown.findAll(GlDropdownItem); + const searchBox = dropdown.findComponent(GlSearchBoxByType); + const dropdownItems = dropdown.findAllComponents(GlDropdownItem); const { nodes } = parsedMapping.samplePayload.payloadAlerFields; - const numberOfMappingOptions = nodes.filter(({ type }) => - type.some((t) => compatibleTypes.includes(t)), - ); + const mappingOptions = nodes.filter(({ type }) => compatibleTypes.includes(type)); expect(searchBox.exists()).toBe(Boolean(numberOfFallbacks)); - expect(dropdownItems).toHaveLength(numberOfMappingOptions.length); + expect(dropdownItems).toHaveLength(mappingOptions.length); } }); }); + + it('emits event with selected mapping', () => { + const mappingToSave = { fieldName: 'TITLE', mapping: 'PARSED_TITLE' }; + jest.spyOn(transformationUtils, 'transformForSave').mockReturnValue(mappingToSave); + const dropdown = findColumnInRow(1, 2).find(GlDropdown); + const option = dropdown.find(GlDropdownItem); + option.vm.$emit('click'); + expect(wrapper.emitted('onMappingUpdate')[0]).toEqual([mappingToSave]); + }); }); diff --git a/spec/frontend/alerts_settings/alerts_integrations_list_spec.js b/spec/frontend/alerts_settings/components/alerts_integrations_list_spec.js index 5a3874d055b..5a3874d055b 100644 --- a/spec/frontend/alerts_settings/alerts_integrations_list_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_integrations_list_spec.js diff --git a/spec/frontend/alerts_settings/alerts_settings_form_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js index 21cdec6f94c..4f8983b1aca 100644 --- a/spec/frontend/alerts_settings/alerts_settings_form_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js @@ -9,6 +9,7 @@ import { } from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue'; +import MappingBuilder from '~/alerts_settings/components/alert_mapping_builder.vue'; import { defaultAlertSettingsConfig } from './util'; import { typeSet } from '~/alerts_settings/constants'; @@ -49,6 +50,7 @@ describe('AlertsSettingsFormNew', () => { const findFormToggle = () => wrapper.find(GlToggle); const findTestPayloadSection = () => wrapper.find(`[id = "test-integration"]`); const findMappingBuilderSection = () => wrapper.find(`[id = "mapping-builder"]`); + const findMappingBuilder = () => wrapper.findComponent(MappingBuilder); const findSubmitButton = () => wrapper.find(`[type = "submit"]`); const findMultiSupportText = () => wrapper.find(`[data-testid="multi-integrations-not-supported"]`); @@ -63,6 +65,16 @@ describe('AlertsSettingsFormNew', () => { } }); + const selectOptionAtIndex = async (index) => { + const options = findSelect().findAll('option'); + await options.at(index).setSelected(); + }; + + const enableIntegration = (index, value) => { + findFormFields().at(index).setValue(value); + findFormToggle().trigger('click'); + }; + describe('with default values', () => { beforeEach(() => { createComponent(); @@ -80,10 +92,7 @@ describe('AlertsSettingsFormNew', () => { }); it('shows the rest of the form when the dropdown is used', async () => { - const options = findSelect().findAll('option'); - await options.at(1).setSelected(); - - await wrapper.vm.$nextTick(); + await selectOptionAtIndex(1); expect(findFormFields().at(0).isVisible()).toBe(true); }); @@ -96,120 +105,128 @@ describe('AlertsSettingsFormNew', () => { it('disabled the name input when the selected value is prometheus', async () => { createComponent(); - const options = findSelect().findAll('option'); - await options.at(2).setSelected(); + await selectOptionAtIndex(2); expect(findFormFields().at(0).attributes('disabled')).toBe('disabled'); }); }); describe('submitting integration form', () => { - it('allows for create-new-integration with the correct form values for HTTP', async () => { - createComponent(); + describe('HTTP', () => { + it('create', async () => { + createComponent(); - const options = findSelect().findAll('option'); - await options.at(1).setSelected(); + const integrationName = 'Test integration'; + await selectOptionAtIndex(1); + enableIntegration(0, integrationName); - await findFormFields().at(0).setValue('Test integration'); - await findFormToggle().trigger('click'); + const submitBtn = findSubmitButton(); + expect(submitBtn.exists()).toBe(true); + expect(submitBtn.text()).toBe('Save integration'); - await wrapper.vm.$nextTick(); - - expect(findSubmitButton().exists()).toBe(true); - expect(findSubmitButton().text()).toBe('Save integration'); - - findForm().trigger('submit'); + findForm().trigger('submit'); - await wrapper.vm.$nextTick(); - - expect(wrapper.emitted('create-new-integration')).toBeTruthy(); - expect(wrapper.emitted('create-new-integration')[0]).toEqual([ - { type: typeSet.http, variables: { name: 'Test integration', active: true } }, - ]); - }); + expect(wrapper.emitted('create-new-integration')[0]).toEqual([ + { type: typeSet.http, variables: { name: integrationName, active: true } }, + ]); + }); - it('allows for create-new-integration with the correct form values for PROMETHEUS', async () => { - createComponent(); + it('create with custom mapping', async () => { + createComponent({ multipleHttpIntegrationsCustomMapping: true }); - const options = findSelect().findAll('option'); - await options.at(2).setSelected(); + const integrationName = 'Test integration'; + await selectOptionAtIndex(1); - await findFormFields().at(0).setValue('Test integration'); - await findFormFields().at(1).setValue('https://test.com'); - await findFormToggle().trigger('click'); + enableIntegration(0, integrationName); - await wrapper.vm.$nextTick(); + const sampleMapping = { field: 'test' }; + findMappingBuilder().vm.$emit('onMappingUpdate', sampleMapping); + findForm().trigger('submit'); - expect(findSubmitButton().exists()).toBe(true); - expect(findSubmitButton().text()).toBe('Save integration'); + expect(wrapper.emitted('create-new-integration')[0]).toEqual([ + { + type: typeSet.http, + variables: { + name: integrationName, + active: true, + payloadAttributeMappings: sampleMapping, + payloadExample: null, + }, + }, + ]); + }); - findForm().trigger('submit'); + it('update', () => { + createComponent({ + data: { + selectedIntegration: typeSet.http, + currentIntegration: { id: '1', name: 'Test integration pre' }, + }, + props: { + loading: false, + }, + }); + const updatedIntegrationName = 'Test integration post'; + enableIntegration(0, updatedIntegrationName); - await wrapper.vm.$nextTick(); + const submitBtn = findSubmitButton(); + expect(submitBtn.exists()).toBe(true); + expect(submitBtn.text()).toBe('Save integration'); - expect(wrapper.emitted('create-new-integration')).toBeTruthy(); - expect(wrapper.emitted('create-new-integration')[0]).toEqual([ - { type: typeSet.prometheus, variables: { apiUrl: 'https://test.com', active: true } }, - ]); - }); + findForm().trigger('submit'); - it('allows for update-integration with the correct form values for HTTP', async () => { - createComponent({ - data: { - selectedIntegration: typeSet.http, - currentIntegration: { id: '1', name: 'Test integration pre' }, - }, - props: { - loading: false, - }, + expect(wrapper.emitted('update-integration')[0]).toEqual([ + { type: typeSet.http, variables: { name: updatedIntegrationName, active: true } }, + ]); }); + }); - await findFormFields().at(0).setValue('Test integration post'); - await findFormToggle().trigger('click'); + describe('PROMETHEUS', () => { + it('create', async () => { + createComponent(); - await wrapper.vm.$nextTick(); + await selectOptionAtIndex(2); - expect(findSubmitButton().exists()).toBe(true); - expect(findSubmitButton().text()).toBe('Save integration'); + const apiUrl = 'https://test.com'; + enableIntegration(1, apiUrl); - findForm().trigger('submit'); + findFormToggle().trigger('click'); - await wrapper.vm.$nextTick(); + const submitBtn = findSubmitButton(); + expect(submitBtn.exists()).toBe(true); + expect(submitBtn.text()).toBe('Save integration'); - expect(wrapper.emitted('update-integration')).toBeTruthy(); - expect(wrapper.emitted('update-integration')[0]).toEqual([ - { type: typeSet.http, variables: { name: 'Test integration post', active: true } }, - ]); - }); + findForm().trigger('submit'); - it('allows for update-integration with the correct form values for PROMETHEUS', async () => { - createComponent({ - data: { - selectedIntegration: typeSet.prometheus, - currentIntegration: { id: '1', apiUrl: 'https://test-pre.com' }, - }, - props: { - loading: false, - }, + expect(wrapper.emitted('create-new-integration')[0]).toEqual([ + { type: typeSet.prometheus, variables: { apiUrl, active: true } }, + ]); }); - await findFormFields().at(0).setValue('Test integration'); - await findFormFields().at(1).setValue('https://test-post.com'); - await findFormToggle().trigger('click'); - - await wrapper.vm.$nextTick(); + it('update', () => { + createComponent({ + data: { + selectedIntegration: typeSet.prometheus, + currentIntegration: { id: '1', apiUrl: 'https://test-pre.com' }, + }, + props: { + loading: false, + }, + }); - expect(findSubmitButton().exists()).toBe(true); - expect(findSubmitButton().text()).toBe('Save integration'); + const apiUrl = 'https://test-post.com'; + enableIntegration(1, apiUrl); - findForm().trigger('submit'); + const submitBtn = findSubmitButton(); + expect(submitBtn.exists()).toBe(true); + expect(submitBtn.text()).toBe('Save integration'); - await wrapper.vm.$nextTick(); + findForm().trigger('submit'); - expect(wrapper.emitted('update-integration')).toBeTruthy(); - expect(wrapper.emitted('update-integration')[0]).toEqual([ - { type: typeSet.prometheus, variables: { apiUrl: 'https://test-post.com', active: true } }, - ]); + expect(wrapper.emitted('update-integration')[0]).toEqual([ + { type: typeSet.prometheus, variables: { apiUrl, active: true } }, + ]); + }); }); }); @@ -234,9 +251,10 @@ describe('AlertsSettingsFormNew', () => { jest.runAllTimers(); await wrapper.vm.$nextTick(); - expect(findJsonTestSubmit().exists()).toBe(true); - expect(findJsonTestSubmit().text()).toBe('Save and test payload'); - expect(findJsonTestSubmit().props('disabled')).toBe(true); + const jsonTestSubmit = findJsonTestSubmit(); + expect(jsonTestSubmit.exists()).toBe(true); + expect(jsonTestSubmit.text()).toBe('Save and test payload'); + expect(jsonTestSubmit.props('disabled')).toBe(true); }); it('should allow for the form to be automatically saved if the test payload is successfully submitted', async () => { @@ -341,9 +359,8 @@ describe('AlertsSettingsFormNew', () => { it(`${visibleMsg} when multipleHttpIntegrationsCustomMapping feature flag ${featureFlagMsg} and integration type is ${integrationType}`, async () => { createComponent({ multipleHttpIntegrationsCustomMapping: featureFlag }); - const options = findSelect().findAll('option'); - options.at(integrationOption).setSelected(); - await wrapper.vm.$nextTick(); + await selectOptionAtIndex(integrationOption); + expect(findMappingBuilderSection().exists()).toBe(visible); }); }); diff --git a/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js index 4d0732ca76c..4d0732ca76c 100644 --- a/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js diff --git a/spec/frontend/alerts_settings/mocks/apollo_mock.js b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js index e0eba1e8421..e0eba1e8421 100644 --- a/spec/frontend/alerts_settings/mocks/apollo_mock.js +++ b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js diff --git a/spec/frontend/alerts_settings/mocks/integrations.json b/spec/frontend/alerts_settings/components/mocks/integrations.json index b1284fc55a2..b1284fc55a2 100644 --- a/spec/frontend/alerts_settings/mocks/integrations.json +++ b/spec/frontend/alerts_settings/components/mocks/integrations.json diff --git a/spec/frontend/alerts_settings/util.js b/spec/frontend/alerts_settings/components/util.js index 5c07f22f1c9..5c07f22f1c9 100644 --- a/spec/frontend/alerts_settings/util.js +++ b/spec/frontend/alerts_settings/components/util.js diff --git a/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js b/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js new file mode 100644 index 00000000000..f725712fdd2 --- /dev/null +++ b/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js @@ -0,0 +1,80 @@ +import { + getMappingData, + getPayloadFields, + transformForSave, +} from '~/alerts_settings/utils/mapping_transformations'; +import gitlabFieldsMock from '~/alerts_settings/components/mocks/gitlabFields.json'; +import parsedMapping from '~/alerts_settings/components/mocks/parsedMapping.json'; + +describe('Mapping Transformation Utilities', () => { + const nameField = { + label: 'Name', + path: ['alert', 'name'], + type: 'STRING', + }; + const dashboardField = { + label: 'Dashboard Id', + path: ['alert', 'dashboardId'], + type: 'STRING', + }; + + describe('getMappingData', () => { + it('should return mapping data', () => { + const alertFields = gitlabFieldsMock.slice(0, 3); + const result = getMappingData( + alertFields, + getPayloadFields(parsedMapping.samplePayload.payloadAlerFields.nodes.slice(0, 3)), + parsedMapping.storedMapping.nodes.slice(0, 3), + ); + + result.forEach((data, index) => { + expect(data).toEqual( + expect.objectContaining({ + ...alertFields[index], + searchTerm: '', + fallbackSearchTerm: '', + }), + ); + }); + }); + }); + + describe('transformForSave', () => { + it('should transform mapped data for save', () => { + const fieldName = 'title'; + const mockMappingData = [ + { + name: fieldName, + mapping: 'alert_name', + mappingFields: getPayloadFields([dashboardField, nameField]), + }, + ]; + const result = transformForSave(mockMappingData); + const { path, type, label } = nameField; + expect(result).toEqual([{ fieldName, path, type, label }]); + }); + + it('should return empty array if no mapping provided', () => { + const fieldName = 'title'; + const mockMappingData = [ + { + name: fieldName, + mapping: null, + mappingFields: getPayloadFields([nameField, dashboardField]), + }, + ]; + const result = transformForSave(mockMappingData); + expect(result).toEqual([]); + }); + }); + + describe('getPayloadFields', () => { + it('should add name field to each payload field', () => { + const result = getPayloadFields([nameField, dashboardField]); + expect(result).toEqual([ + { ...nameField, name: 'alert_name' }, + { ...dashboardField, name: 'alert_dashboardId' }, + ]); + }); + }); +}); |