diff --git a/app/assets/javascripts/ide/components/oauth_domain_mismatch_error.vue b/app/assets/javascripts/ide/components/oauth_domain_mismatch_error.vue index 9899e941fbfe9b6342e527340655acf6b9a203bb..24dfba585782079af0eca204d16dc9df7052ea6a 100644 --- a/app/assets/javascripts/ide/components/oauth_domain_mismatch_error.vue +++ b/app/assets/javascripts/ide/components/oauth_domain_mismatch_error.vue @@ -1,41 +1,38 @@ <script> -import { GlButton, GlSprintf, GlCollapsibleListbox, GlIcon } from '@gitlab/ui'; +import { GlButton, GlSprintf, GlDisclosureDropdown } from '@gitlab/ui'; import GITLAB_LOGO_SVG_URL from '@gitlab/svgs/dist/illustrations/gitlab_logo.svg?url'; import { s__ } from '~/locale'; -import { logError } from '~/lib/logger'; +import { joinPaths, stripRelativeUrlRootFromPath } from '~/lib/utils/url_utility'; export default { name: 'OAuthDomainMismatchError', components: { GlButton, GlSprintf, - GlCollapsibleListbox, - GlIcon, + GlDisclosureDropdown, }, props: { - callbackUrlOrigins: { + expectedCallbackUrl: { + type: String, + required: true, + }, + callbackUrls: { type: Array, required: true, }, }, computed: { dropdownItems() { - return this.callbackUrlOrigins.map((domain) => { - return { - value: domain, - text: domain, - }; - }); - }, - }, - methods: { - reloadPage(urlDomain) { - try { - const current = new URL(urlDomain + window.location.pathname); - window.location.replace(current.toString()); - } catch (e) { - logError(s__('IDE|Error reloading page'), e); - } + const currentOrigin = window.location.origin; + + return this.callbackUrls + .filter(({ base }) => new URL(base).origin !== currentOrigin) + .map(({ base }) => { + return { + href: joinPaths(base, stripRelativeUrlRootFromPath(window.location.pathname)), + text: base, + }; + }); }, }, gitlabLogo: GITLAB_LOGO_SVG_URL, @@ -50,6 +47,7 @@ export default { description: s__( "IDE|The URL you're using to access the Web IDE and the configured OAuth callback URL do not match. This issue often occurs when you're using a proxy.", ), + expected: s__('IDE|Could not find a callback URL entry for %{expectedCallbackUrl}.'), contact: s__( 'IDE|Contact your administrator or try to open the Web IDE again with another domain.', ), @@ -64,27 +62,28 @@ export default { <p> {{ $options.i18n.description }} </p> + <gl-sprintf :message="$options.i18n.expected"> + <template #expectedCallbackUrl> + <code>{{ expectedCallbackUrl }}</code> + </template> + </gl-sprintf> <p> {{ $options.i18n.contact }} </p> <div class="gl-mt-6"> - <gl-collapsible-listbox - v-if="callbackUrlOrigins.length > 1" + <gl-disclosure-dropdown + v-if="dropdownItems.length > 1" :items="dropdownItems" - :header-text="$options.i18n.dropdownHeader" - @select="reloadPage" + :toggle-text="$options.i18n.buttonText.domains" + /> + <gl-button + v-else-if="dropdownItems.length === 1" + variant="confirm" + :href="dropdownItems[0].href" > - <template #toggle> - <gl-button variant="confirm" class="self-center"> - {{ $options.i18n.buttonText.domains }} - <gl-icon class="dropdown-chevron gl-ml-2" name="chevron-down" /> - </gl-button> - </template> - </gl-collapsible-listbox> - <gl-button v-else variant="confirm" @click="reloadPage(callbackUrlOrigins[0])"> <gl-sprintf :message="$options.i18n.buttonText.singleDomain"> <template #domain> - {{ callbackUrlOrigins[0] }} + {{ dropdownItems[0].text }} </template> </gl-sprintf> </gl-button> diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index f754de6727c13fd8af14ef286aa71d89bbdcc216..c92fe21068d50c0576eba832829955ac9c37f0f6 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -112,3 +112,7 @@ export const DEFAULT_BRANCH = 'main'; export const GITLAB_WEB_IDE_FEEDBACK_ISSUE = 'https://gitlab.com/gitlab-org/gitlab/-/issues/377367'; export const IDE_ELEMENT_ID = 'ide'; + +// note: This path comes from `config/routes.rb` +export const IDE_PATH = '/-/ide'; +export const WEB_IDE_OAUTH_CALLBACK_URL_PATH = '/-/ide/oauth_redirect'; diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 020f8e98da19dad9d81837cef9e200b1b4a69c27..4b856550f2b943cfdf1c8e1c0e9fc304a7917ab1 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -24,24 +24,21 @@ export async function startIde(options) { return; } - const oAuthCallbackDomainMismatchApp = new OAuthCallbackDomainMismatchErrorApp( - ideElement, - ideElement.dataset.callbackUrls, - ); - - if (oAuthCallbackDomainMismatchApp.isVisitingFromNonRegisteredOrigin()) { - oAuthCallbackDomainMismatchApp.renderError(); - return; - } - const useNewWebIde = parseBoolean(ideElement.dataset.useNewWebIde); - if (useNewWebIde) { - const { initGitlabWebIDE } = await import('./init_gitlab_web_ide'); - initGitlabWebIDE(ideElement); - } else { + if (!useNewWebIde) { resetServiceWorkersPublicPath(); const { initLegacyWebIDE } = await import('./init_legacy_web_ide'); initLegacyWebIDE(ideElement, options); + return; + } + + const oAuthCallbackDomainMismatchApp = new OAuthCallbackDomainMismatchErrorApp(ideElement); + + if (oAuthCallbackDomainMismatchApp.shouldRenderError()) { + oAuthCallbackDomainMismatchApp.renderError(); + return; } + const { initGitlabWebIDE } = await import('./init_gitlab_web_ide'); + initGitlabWebIDE(ideElement); } diff --git a/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js index e131fb669ead710aa239cd3753135b55cc34608e..76a79fddf23760b35dc6f20308c634729ffacab2 100644 --- a/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js +++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js @@ -10,6 +10,6 @@ const getGitLabUrl = (gitlabPath = '') => { export const getBaseConfig = () => ({ // baseUrl - The URL which hosts the Web IDE static web assets baseUrl: getGitLabUrl(process.env.GITLAB_WEB_IDE_PUBLIC_PATH), - // baseUrl - The URL for the GitLab instance + // gitlabUrl - The URL for the GitLab instance gitlabUrl: getGitLabUrl(''), }); diff --git a/app/assets/javascripts/ide/lib/gitlab_web_ide/get_oauth_config.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_oauth_config.js index bcd3d240c8cb1c429c5a7665d844227eae1a484e..66a36896a31628cd2f7de64ade67e0149f258218 100644 --- a/app/assets/javascripts/ide/lib/gitlab_web_ide/get_oauth_config.js +++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_oauth_config.js @@ -1,4 +1,4 @@ -export const WEB_IDE_OAUTH_CALLBACK_URL_PATH = '/-/ide/oauth_redirect'; +import { getOAuthCallbackUrl } from './oauth_callback_urls'; export const getOAuthConfig = ({ clientId }) => { if (!clientId) { @@ -8,7 +8,7 @@ export const getOAuthConfig = ({ clientId }) => { return { type: 'oauth', clientId, - callbackUrl: new URL(WEB_IDE_OAUTH_CALLBACK_URL_PATH, window.location.origin).toString(), + callbackUrl: getOAuthCallbackUrl(), protectRefreshToken: true, }; }; diff --git a/app/assets/javascripts/ide/lib/gitlab_web_ide/oauth_callback_urls.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/oauth_callback_urls.js new file mode 100644 index 0000000000000000000000000000000000000000..0ea0b83564f0a96760d0298b5399803ee5e98f52 --- /dev/null +++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/oauth_callback_urls.js @@ -0,0 +1,75 @@ +import { joinPaths } from '~/lib/utils/url_utility'; +import { logError } from '~/lib/logger'; +import { WEB_IDE_OAUTH_CALLBACK_URL_PATH, IDE_PATH } from '../../constants'; + +/** + * @returns callback URL constructed from current window url + */ +export function getOAuthCallbackUrl() { + const url = window.location.href; + + // We don't rely on `gon.gitlab_url` and `gon.relative_url_root` here because these may not be configured correctly + // or we're visiting the instance through a proxy. + // Instead, we split on the `/-/ide` in the `href` and use the first part as the base URL. + const baseUrl = url.split(IDE_PATH, 2)[0]; + const callbackUrl = joinPaths(baseUrl, WEB_IDE_OAUTH_CALLBACK_URL_PATH); + + return callbackUrl; +} + +const parseCallbackUrl = (urlStr) => { + let callbackUrl; + + try { + callbackUrl = new URL(urlStr); + } catch { + // Not a valid URL. Nothing to do here. + return undefined; + } + + // If we're an unexpected callback URL + if (!callbackUrl.pathname.endsWith(WEB_IDE_OAUTH_CALLBACK_URL_PATH)) { + return { + base: joinPaths(callbackUrl.origin, '/'), + url: urlStr, + }; + } + + // Else, trim the expected bit to get the origin + relative_url_root + const callbackRelativePath = callbackUrl.pathname.substring( + 0, + callbackUrl.pathname.length - WEB_IDE_OAUTH_CALLBACK_URL_PATH.length, + ); + const baseUrl = new URL(callbackUrl); + baseUrl.pathname = callbackRelativePath; + baseUrl.hash = ''; + baseUrl.search = ''; + + return { + base: joinPaths(baseUrl.toString(), '/'), + url: urlStr, + }; +}; + +export const parseCallbackUrls = (callbackUrlsJson) => { + if (!callbackUrlsJson) { + return []; + } + + let urls; + + try { + urls = JSON.parse(callbackUrlsJson); + } catch { + // why: We dont want to translate console errors + // eslint-disable-next-line @gitlab/require-i18n-strings + logError('Failed to parse callback URLs JSON'); + return []; + } + + if (!urls || !Array.isArray(urls)) { + return []; + } + + return urls.map(parseCallbackUrl).filter(Boolean); +}; diff --git a/app/assets/javascripts/ide/oauth_callback_domain_mismatch_error.js b/app/assets/javascripts/ide/oauth_callback_domain_mismatch_error.js index 6598fe62d4979eca3c159df6d2bf59564b802aa2..bf9678bc498133916978e25a0a3fea58e923fefa 100644 --- a/app/assets/javascripts/ide/oauth_callback_domain_mismatch_error.js +++ b/app/assets/javascripts/ide/oauth_callback_domain_mismatch_error.js @@ -1,48 +1,43 @@ import Vue from 'vue'; import OAuthDomainMismatchError from './components/oauth_domain_mismatch_error.vue'; +import { parseCallbackUrls, getOAuthCallbackUrl } from './lib/gitlab_web_ide/oauth_callback_urls'; export class OAuthCallbackDomainMismatchErrorApp { #el; - #callbackUrlOrigins; + #callbackUrls; + #expectedCallbackUrl; - constructor(el, callbackUrls) { + constructor(el) { this.#el = el; - this.#callbackUrlOrigins = - OAuthCallbackDomainMismatchErrorApp.#getCallbackUrlOrigins(callbackUrls); + this.#callbackUrls = parseCallbackUrls(el.dataset.callbackUrls); + this.#expectedCallbackUrl = getOAuthCallbackUrl(); } - isVisitingFromNonRegisteredOrigin() { - return ( - this.#callbackUrlOrigins.length && !this.#callbackUrlOrigins.includes(window.location.origin) - ); + shouldRenderError() { + if (!this.#callbackUrls.length) { + return false; + } + + return this.#callbackUrls.every(({ url }) => url !== this.#expectedCallbackUrl); } renderError() { - const callbackUrlOrigins = this.#callbackUrlOrigins; + const callbackUrls = this.#callbackUrls; + const expectedCallbackUrl = this.#expectedCallbackUrl; const el = this.#el; if (!el) return null; return new Vue({ el, - data() { - return { - callbackUrlOrigins, - }; - }, render(createElement) { return createElement(OAuthDomainMismatchError, { props: { - callbackUrlOrigins, + expectedCallbackUrl, + callbackUrls, }, }); }, }); } - - static #getCallbackUrlOrigins(callbackUrls) { - if (!callbackUrls) return []; - - return JSON.parse(callbackUrls).map((url) => new URL(url).origin); - } } diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index b556595bc67e97ddb27002b9b16aaa4e6c1bf1d9..48b7cb86bda46795bba8f2e582556afca006ab41 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -780,3 +780,14 @@ export function buildURLwithRefType({ base = window.location.origin, path, refTy } return url.pathname + url.search; } + +export function stripRelativeUrlRootFromPath(path) { + const relativeUrlRoot = joinPaths(window.gon.relative_url_root, '/'); + + // If we have no relative url root or path doesn't start with it, just return the path + if (relativeUrlRoot === '/' || !path.startsWith(relativeUrlRoot)) { + return path; + } + + return joinPaths('/', path.substring(relativeUrlRoot.length)); +} diff --git a/config/routes.rb b/config/routes.rb index 44903738510aa886847f72a94607328a54338037..3fede72eebb3b5a0e53ef5648b89e810f4b51c96 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -145,6 +145,7 @@ scope :ide, as: :ide, format: false do get '/', to: 'ide#index' get '/project', to: 'ide#index' + # note: This path has a hardcoded reference in the FE `app/assets/javascripts/ide/constants.js` get '/oauth_redirect', to: 'ide#oauth_redirect' scope path: 'project/:project_id', as: :project, constraints: { project_id: Gitlab::PathRegex.full_namespace_route_regex } do diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 4620d62ae9c6245e7792227f0b0469ee0bdd14db..560e5e580c160e9f2be4d5f4948058c3cb2b6bdf 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -26939,13 +26939,13 @@ msgstr "" msgid "IDE|Contact your administrator or try to open the Web IDE again with another domain." msgstr "" -msgid "IDE|Edit" +msgid "IDE|Could not find a callback URL entry for %{expectedCallbackUrl}." msgstr "" -msgid "IDE|Editing this application might affect the functionality of the Web IDE. Ensure the configuration meets the following conditions:" +msgid "IDE|Edit" msgstr "" -msgid "IDE|Error reloading page" +msgid "IDE|Editing this application might affect the functionality of the Web IDE. Ensure the configuration meets the following conditions:" msgstr "" msgid "IDE|GitLab logo" diff --git a/package.json b/package.json index 6a603dfde294cfb5561c24581cc3518fd68c8b6e..42c848bcb177a82e5932a3723c0a8b8e9c7cc475 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "@gitlab/query-language": "^0.0.5-a-20240806", "@gitlab/svgs": "3.112.0", "@gitlab/ui": "89.0.0", - "@gitlab/web-ide": "^0.0.1-dev-20240731185426", + "@gitlab/web-ide": "^0.0.1-dev-20240813211849", "@mattiasbuelens/web-streams-adapter": "^0.1.0", "@rails/actioncable": "7.0.8-4", "@rails/ujs": "7.0.8-4", diff --git a/patches/@gitlab+web-ide+0.0.1-dev-20240731185426.patch b/patches/@gitlab+web-ide+0.0.1-dev-20240813211849.patch similarity index 99% rename from patches/@gitlab+web-ide+0.0.1-dev-20240731185426.patch rename to patches/@gitlab+web-ide+0.0.1-dev-20240813211849.patch index 815bfb36eebc2b219942883365f42319c34c83d4..0b40e9ec7d4019edd6add822d43fadec777615e5 100644 --- a/patches/@gitlab+web-ide+0.0.1-dev-20240731185426.patch +++ b/patches/@gitlab+web-ide+0.0.1-dev-20240813211849.patch @@ -2950,5 +2950,5 @@ index 6a16dd1..99b1df4 100644 - const parentOrigin = searchParams.get('parentOrigin') || window.origin; + const parentOrigin = window.origin; const salt = searchParams.get('salt'); - + (async function () { diff --git a/spec/frontend/ide/components/oauth_domain_mismatch_error_spec.js b/spec/frontend/ide/components/oauth_domain_mismatch_error_spec.js index 1ce0201f5e9e006059971a0a90041e1edfd0fc2f..2de27b26aaea4cbea7a4bbcc4d1103dde756568b 100644 --- a/spec/frontend/ide/components/oauth_domain_mismatch_error_spec.js +++ b/spec/frontend/ide/components/oauth_domain_mismatch_error_spec.js @@ -1,87 +1,111 @@ -import { GlButton, GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; +import { GlButton, GlDisclosureDropdown } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { TEST_HOST } from 'helpers/test_constants'; import OAuthDomainMismatchError from '~/ide/components/oauth_domain_mismatch_error.vue'; -const MOCK_CALLBACK_URL_ORIGIN = 'https://example1.com'; -const MOCK_PATH_NAME = '/path/to/ide'; +const MOCK_CALLBACK_URLS = [ + { + base: 'https://example1.com/', + }, + { + base: 'https://example2.com/', + }, + { + base: 'https://example3.com/relative-path/', + }, +]; +const MOCK_CALLBACK_URL = 'https://example.com'; +const MOCK_PATH_NAME = 'path/to/ide'; + +const EXPECTED_DROPDOWN_ITEMS = MOCK_CALLBACK_URLS.map(({ base }) => ({ + text: base, + href: `${base}${MOCK_PATH_NAME}`, +})); describe('OAuthDomainMismatchError', () => { - useMockLocationHelper(); - let wrapper; - let originalLocation; const findButton = () => wrapper.findComponent(GlButton); - const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox); - const findDropdownItems = () => wrapper.findAllComponents(GlListboxItem); + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); const createWrapper = (props = {}) => { wrapper = mount(OAuthDomainMismatchError, { propsData: { - callbackUrlOrigins: [MOCK_CALLBACK_URL_ORIGIN], + expectedCallbackUrl: MOCK_CALLBACK_URL, + callbackUrls: MOCK_CALLBACK_URLS, ...props, }, }); }; beforeEach(() => { - originalLocation = window.location; - window.location.pathname = MOCK_PATH_NAME; - }); - - afterEach(() => { - window.location = originalLocation; + setWindowLocation(`/${MOCK_PATH_NAME}`); }); describe('single callback URL domain passed', () => { beforeEach(() => { - createWrapper(); + createWrapper({ + callbackUrls: MOCK_CALLBACK_URLS.slice(0, 1), + }); + }); + + it('renders expected callback URL message', () => { + expect(wrapper.text()).toContain( + `Could not find a callback URL entry for ${MOCK_CALLBACK_URL}.`, + ); }); it('does not render dropdown', () => { expect(findDropdown().exists()).toBe(false); }); - it('reloads page with correct url on button click', async () => { - findButton().vm.$emit('click'); - await nextTick(); - - expect(window.location.replace).toHaveBeenCalledTimes(1); - expect(window.location.replace).toHaveBeenCalledWith( - new URL(MOCK_CALLBACK_URL_ORIGIN + MOCK_PATH_NAME).toString(), - ); + it('renders button with correct attributes', () => { + const button = findButton(); + expect(button.exists()).toBe(true); + const baseUrl = MOCK_CALLBACK_URLS[0].base; + expect(button.text()).toContain(baseUrl); + expect(button.attributes('href')).toBe(`${baseUrl}${MOCK_PATH_NAME}`); }); }); describe('multiple callback URL domains passed', () => { - const MOCK_CALLBACK_URL_ORIGINS = [MOCK_CALLBACK_URL_ORIGIN, 'https://example2.com']; - beforeEach(() => { - createWrapper({ callbackUrlOrigins: MOCK_CALLBACK_URL_ORIGINS }); + createWrapper(); }); - it('renders dropdown', () => { - expect(findDropdown().exists()).toBe(true); + it('renders dropdown with correct items', () => { + const dropdown = findDropdown(); + + expect(dropdown.exists()).toBe(true); + expect(dropdown.props('items')).toStrictEqual(EXPECTED_DROPDOWN_ITEMS); + }); + }); + + describe('with erroneous callback from current origin', () => { + beforeEach(() => { + createWrapper({ + callbackUrls: MOCK_CALLBACK_URLS.concat({ + base: `${TEST_HOST}/foo`, + }), + }); }); - it('renders dropdown items', () => { - const dropdownItems = findDropdownItems(); - expect(dropdownItems.length).toBe(MOCK_CALLBACK_URL_ORIGINS.length); - expect(dropdownItems.at(0).text()).toBe(MOCK_CALLBACK_URL_ORIGINS[0]); - expect(dropdownItems.at(1).text()).toBe(MOCK_CALLBACK_URL_ORIGINS[1]); + it('filters out item with current origin', () => { + expect(findDropdown().props('items')).toStrictEqual(EXPECTED_DROPDOWN_ITEMS); }); + }); - it('reloads page with correct url on dropdown item click', async () => { - const dropdownItem = findDropdownItems().at(0); - dropdownItem.vm.$emit('select', MOCK_CALLBACK_URL_ORIGIN); - await nextTick(); + describe('when no callback URL passed', () => { + beforeEach(() => { + createWrapper({ + callbackUrls: [], + }); + }); - expect(window.location.replace).toHaveBeenCalledTimes(1); - expect(window.location.replace).toHaveBeenCalledWith( - new URL(MOCK_CALLBACK_URL_ORIGIN + MOCK_PATH_NAME).toString(), - ); + it('does not render dropdown or button', () => { + expect(findDropdown().exists()).toBe(false); + expect(findButton().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/ide/helpers.js b/spec/frontend/ide/helpers.js index e5797ca857530c1f6b67c739ed8f76016e20c676..89d3b14c43299ce94f8ec0c05368218eb57a9aa3 100644 --- a/spec/frontend/ide/helpers.js +++ b/spec/frontend/ide/helpers.js @@ -1,7 +1,6 @@ import * as pathUtils from 'path'; -import { commitActionTypes } from '~/ide/constants'; +import { WEB_IDE_OAUTH_CALLBACK_URL_PATH, commitActionTypes } from '~/ide/constants'; import { decorateData } from '~/ide/stores/utils'; -import { WEB_IDE_OAUTH_CALLBACK_URL_PATH } from '~/ide/lib/gitlab_web_ide/get_oauth_config'; // eslint-disable-next-line max-params export const file = (name = 'name', id = name, type = '', parent = null) => diff --git a/spec/frontend/ide/index_spec.js b/spec/frontend/ide/index_spec.js index 414c963aeb763a60e91d35ada8a98f1ea3364364..36a7f1c4b776ecfccdd99d1fd1724b4d8e4bdd6e 100644 --- a/spec/frontend/ide/index_spec.js +++ b/spec/frontend/ide/index_spec.js @@ -2,12 +2,16 @@ import { startIde } from '~/ide/index'; import { IDE_ELEMENT_ID } from '~/ide/constants'; import { OAuthCallbackDomainMismatchErrorApp } from '~/ide/oauth_callback_domain_mismatch_error'; import { initGitlabWebIDE } from '~/ide/init_gitlab_web_ide'; +import { initLegacyWebIDE } from '~/ide/init_legacy_web_ide'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { TEST_HOST } from 'helpers/test_constants'; jest.mock('~/ide/init_gitlab_web_ide'); +jest.mock('~/ide/init_legacy_web_ide'); -const MOCK_CALLBACK_URL = `${window.location.origin}/ide/redirect`; +const MOCK_MISMATCH_CALLBACK_URL = 'https://example.com/ide/redirect'; const MOCK_DATA_SET = { - callbackUrls: JSON.stringify([MOCK_CALLBACK_URL]), + callbackUrls: JSON.stringify([`${TEST_HOST}/-/ide/oauth_redirect`]), useNewWebIde: true, }; /** @@ -27,12 +31,20 @@ const setupMockIdeElement = (customData = MOCK_DATA_SET) => { }; describe('startIde', () => { + let renderErrorSpy; + + beforeEach(() => { + setWindowLocation(`${TEST_HOST}/-/ide/edit/gitlab-org/gitlab`); + renderErrorSpy = jest.spyOn(OAuthCallbackDomainMismatchErrorApp.prototype, 'renderError'); + }); + afterEach(() => { - document.getElementById(IDE_ELEMENT_ID).remove(); + document.getElementById(IDE_ELEMENT_ID)?.remove(); }); describe('when useNewWebIde feature flag is true', () => { let ideElement; + beforeEach(async () => { ideElement = setupMockIdeElement(); @@ -43,43 +55,53 @@ describe('startIde', () => { expect(initGitlabWebIDE).toHaveBeenCalledTimes(1); expect(initGitlabWebIDE).toHaveBeenCalledWith(ideElement); }); - }); - describe('OAuth callback origin mismatch check', () => { - let renderErrorSpy; - - beforeEach(() => { - renderErrorSpy = jest.spyOn(OAuthCallbackDomainMismatchErrorApp.prototype, 'renderError'); + it('does not render error page', () => { + expect(renderErrorSpy).not.toHaveBeenCalled(); }); + }); + + describe('with mismatch callback url', () => { + it('renders error page', async () => { + setupMockIdeElement({ + callbackUrls: JSON.stringify([MOCK_MISMATCH_CALLBACK_URL]), + useNewWebIde: true, + }); - it('does not render error page if no callbackUrl provided', async () => { - setupMockIdeElement({ useNewWebIde: true }); await startIde(); - expect(renderErrorSpy).not.toHaveBeenCalled(); - expect(initGitlabWebIDE).toHaveBeenCalledTimes(1); + expect(renderErrorSpy).toHaveBeenCalledTimes(1); + expect(initGitlabWebIDE).not.toHaveBeenCalled(); }); + }); + + describe('with relative URL location and mismatch callback url', () => { + it('renders error page', async () => { + setWindowLocation(`${TEST_HOST}/relative-path/-/ide/edit/project`); - it('does not call renderOAuthDomainMismatchError if no mismatch detected', async () => { setupMockIdeElement(); + await startIde(); - expect(renderErrorSpy).not.toHaveBeenCalled(); - expect(initGitlabWebIDE).toHaveBeenCalledTimes(1); + expect(renderErrorSpy).toHaveBeenCalledTimes(1); + expect(initGitlabWebIDE).not.toHaveBeenCalled(); }); + }); - it('renders error page if OAuth callback origin does not match window.location.origin', async () => { - const MOCK_MISMATCH_CALLBACK_URL = 'https://example.com/ide/redirect'; - renderErrorSpy.mockImplementation(() => {}); - setupMockIdeElement({ - callbackUrls: JSON.stringify([MOCK_MISMATCH_CALLBACK_URL]), - useNewWebIde: true, - }); + describe('when useNewWebIde feature flag is false', () => { + beforeEach(async () => { + setupMockIdeElement({ useNewWebIde: false }); await startIde(); + }); - expect(renderErrorSpy).toHaveBeenCalledTimes(1); - expect(initGitlabWebIDE).not.toHaveBeenCalled(); + it('calls initGitlabWebIDE', () => { + expect(initLegacyWebIDE).toHaveBeenCalledTimes(1); + expect(initGitlabWebIDE).toHaveBeenCalledTimes(0); + }); + + it('does not render error page', () => { + expect(renderErrorSpy).not.toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/ide/lib/gitlab_web_ide/oauth_callback_urls_spec.js b/spec/frontend/ide/lib/gitlab_web_ide/oauth_callback_urls_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..61a8e3288d8bc9a8db2df8a72599609db90856d2 --- /dev/null +++ b/spec/frontend/ide/lib/gitlab_web_ide/oauth_callback_urls_spec.js @@ -0,0 +1,89 @@ +import { + parseCallbackUrls, + getOAuthCallbackUrl, +} from '~/ide/lib/gitlab_web_ide/oauth_callback_urls'; +import { logError } from '~/lib/logger'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { IDE_PATH, WEB_IDE_OAUTH_CALLBACK_URL_PATH } from '~/ide/constants'; +import setWindowLocation from 'helpers/set_window_location_helper'; + +jest.mock('~/lib/logger'); + +const MOCK_IDE_PATH = joinPaths(IDE_PATH, 'some/path'); + +describe('ide/lib/oauth_callback_urls', () => { + describe('getOAuthCallbackUrl', () => { + const mockPath = MOCK_IDE_PATH; + const MOCK_RELATIVE_PATH = 'relative-path'; + const mockPathWithRelative = joinPaths(MOCK_RELATIVE_PATH, MOCK_IDE_PATH); + + const originalHref = window.location.href; + + afterEach(() => { + setWindowLocation(originalHref); + }); + + const expectedBaseUrlWithRelative = joinPaths(window.location.origin, MOCK_RELATIVE_PATH); + + it.each` + path | expectedCallbackBaseUrl + ${mockPath} | ${window.location.origin} + ${mockPathWithRelative} | ${expectedBaseUrlWithRelative} + `( + 'retrieves expected callback URL based on window url', + ({ path, expectedCallbackBaseUrl }) => { + setWindowLocation(path); + + const actual = getOAuthCallbackUrl(); + const expected = joinPaths(expectedCallbackBaseUrl, WEB_IDE_OAUTH_CALLBACK_URL_PATH); + expect(actual).toEqual(expected); + }, + ); + }); + describe('parseCallbackUrls', () => { + it('parses the given JSON URL array and returns some metadata for them', () => { + const actual = parseCallbackUrls( + JSON.stringify([ + 'https://gitlab.com/-/ide/oauth_redirect', + 'not a url', + 'https://gdk.test:3443/-/ide/oauth_redirect/', + 'https://gdk.test:3443/gitlab/-/ide/oauth_redirect#1234?query=foo', + 'https://example.com/not-a-real-one-/ide/oauth_redirectz', + ]), + ); + + expect(actual).toEqual([ + { + base: 'https://gitlab.com/', + url: 'https://gitlab.com/-/ide/oauth_redirect', + }, + { + base: 'https://gdk.test:3443/', + url: 'https://gdk.test:3443/-/ide/oauth_redirect/', + }, + { + base: 'https://gdk.test:3443/gitlab/', + url: 'https://gdk.test:3443/gitlab/-/ide/oauth_redirect#1234?query=foo', + }, + { + base: 'https://example.com/', + url: 'https://example.com/not-a-real-one-/ide/oauth_redirectz', + }, + ]); + }); + + it('returns empty when given empty', () => { + expect(parseCallbackUrls('')).toEqual([]); + expect(logError).not.toHaveBeenCalled(); + }); + + it('returns empty when not valid JSON', () => { + expect(parseCallbackUrls('babar')).toEqual([]); + expect(logError).toHaveBeenCalledWith('Failed to parse callback URLs JSON'); + }); + + it('returns empty when not array JSON', () => { + expect(parseCallbackUrls('{}')).toEqual([]); + }); + }); +}); diff --git a/spec/frontend/ide/mount_oauth_callback_spec.js b/spec/frontend/ide/mount_oauth_callback_spec.js index 9187fffca811497101fb0d3c42d4cd7edbf09f05..97a9a100ec9418debb9a103cbb1490a357b24590 100644 --- a/spec/frontend/ide/mount_oauth_callback_spec.js +++ b/spec/frontend/ide/mount_oauth_callback_spec.js @@ -46,7 +46,7 @@ describe('~/ide/mount_oauth_callback', () => { clientId: TEST_OAUTH_CLIENT_ID, protectRefreshToken: true, }, - gitlabUrl: TEST_HOST, + gitlabUrl: `${TEST_HOST}`, baseUrl: `${TEST_HOST}/${TEST_GITLAB_WEB_IDE_PUBLIC_PATH}`, username: TEST_USERNAME, }); diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index f9fa2f0e1a53bc820d2233fcb4c5ae5cc2ef5082..0a8c4ee1b2568925144663c5d61ae48434b06f1e 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -1296,4 +1296,21 @@ describe('URL utility', () => { expect(urlUtils.buildURLwithRefType({ base, path, refType })).toBe(output); }); }); + + describe('stripRelativeUrlRootFromPath', () => { + it.each` + relativeUrlRoot | path | expectation + ${''} | ${'/foo/bar'} | ${'/foo/bar'} + ${'/'} | ${'/foo/bar'} | ${'/foo/bar'} + ${'/foo'} | ${'/foo/bar'} | ${'/bar'} + ${'/gitlab/'} | ${'/gitlab/-/ide/foo'} | ${'/-/ide/foo'} + `( + 'with relative_url_root="$relativeUrlRoot", "$path" should return "$expectation"', + ({ relativeUrlRoot, path, expectation }) => { + window.gon.relative_url_root = relativeUrlRoot; + + expect(urlUtils.stripRelativeUrlRootFromPath(path)).toBe(expectation); + }, + ); + }); }); diff --git a/yarn.lock b/yarn.lock index 9a38cd923650f43193aff723947fa605ece08799..8b437a42d21ad68c33faadde6866360984444b0e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1378,10 +1378,10 @@ vue-functional-data-merge "^3.1.0" vue-runtime-helpers "^1.1.2" -"@gitlab/web-ide@^0.0.1-dev-20240731185426": - version "0.0.1-dev-20240731185426" - resolved "https://registry.yarnpkg.com/@gitlab/web-ide/-/web-ide-0.0.1-dev-20240731185426.tgz#dd80adac2286131b08ccb13f590befe80a8e1981" - integrity sha512-Mfnglz0j1UTNtSP+PMIlasbUQMSrUZFjrhUgy9KwdzOIUqe3SdjXs9hAaeYWQ2KAM8libarsqrHTVvstsLJKhw== +"@gitlab/web-ide@^0.0.1-dev-20240813211849": + version "0.0.1-dev-20240813211849" + resolved "https://registry.yarnpkg.com/@gitlab/web-ide/-/web-ide-0.0.1-dev-20240813211849.tgz#da0e6c8ce1137ac7983dd743e0372d6fc8554155" + integrity sha512-JrTgCh+PiWe4pQzdy7KuvIaZjPazoQdBOVzlPzYr7e9tP95w9l5Wy+4I2et4vva0NKRmYYllaqGxxbRTyCCfcA== "@graphql-eslint/eslint-plugin@3.20.1": version "3.20.1" @@ -12914,16 +12914,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -12975,7 +12966,7 @@ string_decoder@^1.0.0, string_decoder@^1.1.1, string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -12989,13 +12980,6 @@ strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -14670,7 +14654,7 @@ worker-loader@^3.0.8: loader-utils "^2.0.0" schema-utils "^3.0.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -14688,15 +14672,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"