diff --git a/app/assets/javascripts/token_access/components/opt_in_jwt.vue b/app/assets/javascripts/token_access/components/opt_in_jwt.vue new file mode 100644 index 0000000000000000000000000000000000000000..2daab124b4da12787c4acbcbe633f62001d86a51 --- /dev/null +++ b/app/assets/javascripts/token_access/components/opt_in_jwt.vue @@ -0,0 +1,125 @@ + + diff --git a/app/assets/javascripts/token_access/components/token_access.vue b/app/assets/javascripts/token_access/components/token_access.vue index fe99f3e1fdd36565eb732687e1f0b3df866bf074..527f01f0a6fdc87524c688c66ba21e14931fbec1 100644 --- a/app/assets/javascripts/token_access/components/token_access.vue +++ b/app/assets/javascripts/token_access/components/token_access.vue @@ -17,6 +17,7 @@ import removeProjectCIJobTokenScopeMutation from '../graphql/mutations/remove_pr import updateCIJobTokenScopeMutation from '../graphql/mutations/update_ci_job_token_scope.mutation.graphql'; import getCIJobTokenScopeQuery from '../graphql/queries/get_ci_job_token_scope.query.graphql'; import getProjectsWithCIJobTokenScopeQuery from '../graphql/queries/get_projects_with_ci_job_token_scope.query.graphql'; +import OptInJwt from './opt_in_jwt.vue'; import TokenProjectsTable from './token_projects_table.vue'; export default { @@ -44,6 +45,7 @@ export default { GlLoadingIcon, GlSprintf, GlToggle, + OptInJwt, TokenProjectsTable, }, inject: { @@ -230,6 +232,7 @@ export default { + diff --git a/app/assets/javascripts/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..c12b5646423e547cdaf000cca075d66a00dcdd95 --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql @@ -0,0 +1,8 @@ +mutation updateOptInJwt($input: CiCdSettingsUpdateInput!) { + ciCdSettingsUpdate(input: $input) { + ciCdSettings { + optInJwt + } + errors + } +} diff --git a/app/assets/javascripts/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..a1a216b7dc35019def10b7dddecb3644410ce9b3 --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql @@ -0,0 +1,8 @@ +query getOptInJwtSetting($fullPath: ID!) { + project(fullPath: $fullPath) { + id + ciCdSettings { + optInJwt + } + } +} diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 5a60905422da7018a9101b980fa88db51fdd7b09..76c3432a81564f41c5917510c393f08b487738c8 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -7688,6 +7688,9 @@ msgstr "" msgid "CICD|Add an existing project to the scope" msgstr "" +msgid "CICD|An error occurred while update the setting. Please try again." +msgstr "" + msgid "CICD|Auto DevOps" msgstr "" @@ -7721,6 +7724,9 @@ msgstr "" msgid "CICD|Limit CI_JOB_TOKEN access" msgstr "" +msgid "CICD|Limit JSON Web Token (JWT) access" +msgstr "" + msgid "CICD|Select the projects that can be accessed by API requests authenticated with this project's CI_JOB_TOKEN CI/CD variable. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API. %{linkStart}Learn more.%{linkEnd}" msgstr "" @@ -7730,15 +7736,24 @@ msgstr "" msgid "CICD|The Auto DevOps pipeline runs if no alternative CI configuration file is found." msgstr "" +msgid "CICD|The JWT must be manually declared in each job that needs it. When disabled, the token is always available in all jobs in the pipeline." +msgstr "" + msgid "CICD|There are several CI/CD limits in place." msgstr "" +msgid "CICD|There was a problem fetching the token access settings." +msgstr "" + msgid "CICD|Unprotected branches will not have access to the cache from protected branches." msgstr "" msgid "CICD|Use separate caches for protected branches" msgstr "" +msgid "CICD|Use the %{codeStart}secrets%{codeEnd} keyword to configure a job with a JWT." +msgstr "" + msgid "CICD|group enabled" msgstr "" diff --git a/spec/frontend/token_access/mock_data.js b/spec/frontend/token_access/mock_data.js index 0c8ba266201bec62e1d1a4ebc4b146e352a25e7a..fff5a0ad4d0dace8128e9b23398d027ef69d5e87 100644 --- a/spec/frontend/token_access/mock_data.js +++ b/spec/frontend/token_access/mock_data.js @@ -105,3 +105,29 @@ export const mockProjects = [ __typename: 'Project', }, ]; + +export const optInJwtQueryResponse = (optInJwt) => ({ + data: { + project: { + id: '1', + ciCdSettings: { + optInJwt, + __typename: 'ProjectCiCdSetting', + }, + __typename: 'Project', + }, + }, +}); + +export const optInJwtMutationResponse = (optInJwt) => ({ + data: { + ciCdSettingsUpdate: { + ciCdSettings: { + optInJwt, + __typename: 'ProjectCiCdSetting', + }, + errors: [], + __typename: 'CiCdSettingsUpdatePayload', + }, + }, +}); diff --git a/spec/frontend/token_access/opt_in_jwt_spec.js b/spec/frontend/token_access/opt_in_jwt_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a25a480f8897d9dc02e8282d233b9ecf036c40bc --- /dev/null +++ b/spec/frontend/token_access/opt_in_jwt_spec.js @@ -0,0 +1,126 @@ +import { GlLoadingIcon, GlToggle } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/flash'; +import OptInJwt from '~/token_access/components/opt_in_jwt.vue'; +import getOptInJwtSettingQuery from '~/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql'; +import updateOptInJwtMutation from '~/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql'; +import { optInJwtMutationResponse, optInJwtQueryResponse } from './mock_data'; + +const errorMessage = 'An error occurred'; +const error = new Error(errorMessage); + +Vue.use(VueApollo); + +jest.mock('~/flash'); + +describe('OptInJwt component', () => { + let wrapper; + + const failureHandler = jest.fn().mockRejectedValue(error); + const enabledOptInJwtHandler = jest.fn().mockResolvedValue(optInJwtQueryResponse(true)); + const disabledOptInJwtHandler = jest.fn().mockResolvedValue(optInJwtQueryResponse(false)); + const updateOptInJwtHandler = jest.fn().mockResolvedValue(optInJwtMutationResponse(true)); + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findToggle = () => wrapper.findComponent(GlToggle); + const findOptInJwtExpandedSection = () => wrapper.findByTestId('opt-in-jwt-expanded-section'); + + const createMockApolloProvider = (requestHandlers) => { + return createMockApollo(requestHandlers); + }; + + const createComponent = (requestHandlers, mountFn = shallowMountExtended) => { + wrapper = mountFn(OptInJwt, { + provide: { + fullPath: 'root/my-repo', + }, + apolloProvider: createMockApolloProvider(requestHandlers), + data() { + return { + targetProjectPath: 'root/test', + }; + }, + }); + }; + + describe('loading state', () => { + it('shows loading state and hides toggle while waiting on query to resolve', async () => { + createComponent([[getOptInJwtSettingQuery, enabledOptInJwtHandler]]); + + expect(findLoadingIcon().exists()).toBe(true); + expect(findToggle().exists()).toBe(false); + + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(false); + expect(findToggle().exists()).toBe(true); + }); + }); + + describe('toggle JWT token access', () => { + it('code instruction is visible when toggle is enabled', async () => { + createComponent([[getOptInJwtSettingQuery, enabledOptInJwtHandler]]); + + await waitForPromises(); + + expect(findToggle().props('value')).toBe(true); + expect(findOptInJwtExpandedSection().exists()).toBe(true); + }); + + it('code instruction is hidden when toggle is disabled', async () => { + createComponent([[getOptInJwtSettingQuery, disabledOptInJwtHandler]]); + + await waitForPromises(); + + expect(findToggle().props('value')).toBe(false); + expect(findOptInJwtExpandedSection().exists()).toBe(false); + }); + + describe('update JWT token access', () => { + it('calls updateOptInJwtMutation with correct arguments', async () => { + createComponent( + [ + [getOptInJwtSettingQuery, disabledOptInJwtHandler], + [updateOptInJwtMutation, updateOptInJwtHandler], + ], + mountExtended, + ); + + await waitForPromises(); + + findToggle().vm.$emit('change', true); + + expect(updateOptInJwtHandler).toHaveBeenCalledWith({ + input: { + fullPath: 'root/my-repo', + optInJwt: true, + }, + }); + }); + + it('handles update error', async () => { + createComponent( + [ + [getOptInJwtSettingQuery, enabledOptInJwtHandler], + [updateOptInJwtMutation, failureHandler], + ], + mountExtended, + ); + + await waitForPromises(); + + findToggle().vm.$emit('change', false); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred while update the setting. Please try again.', + }); + }); + }); + }); +}); diff --git a/spec/frontend/token_access/token_access_spec.js b/spec/frontend/token_access/token_access_spec.js index 6fe94e285480b243734c65092895f660434edd09..62f546463a1f569dc135adc5590e5c8fde8a14ef 100644 --- a/spec/frontend/token_access/token_access_spec.js +++ b/spec/frontend/token_access/token_access_spec.js @@ -5,6 +5,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; +import OptInJwt from '~/token_access/components/opt_in_jwt.vue'; import TokenAccess from '~/token_access/components/token_access.vue'; import addProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/add_project_ci_job_token_scope.mutation.graphql'; import removeProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql'; @@ -40,6 +41,7 @@ describe('TokenAccess component', () => { const failureHandler = jest.fn().mockRejectedValue(error); const findToggle = () => wrapper.findComponent(GlToggle); + const findOptInJwt = () => wrapper.findComponent(OptInJwt); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAddProjectBtn = () => wrapper.findByRole('button', { name: 'Add project' }); const findRemoveProjectBtn = () => wrapper.findByRole('button', { name: 'Remove access' }); @@ -82,6 +84,21 @@ describe('TokenAccess component', () => { }); }); + describe('template', () => { + beforeEach(async () => { + createComponent([ + [getCIJobTokenScopeQuery, enabledJobTokenScopeHandler], + [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScopeHandler], + ]); + + await waitForPromises(); + }); + + it('renders the opt in jwt component', () => { + expect(findOptInJwt().exists()).toBe(true); + }); + }); + describe('fetching projects and scope', () => { it('fetches projects and scope correctly', () => { const expectedVariables = {