diff --git a/src/components/webviews/CozyProxyWebView.functions.js b/src/components/webviews/CozyProxyWebView.functions.js new file mode 100644 index 000000000..eb6f6c84f --- /dev/null +++ b/src/components/webviews/CozyProxyWebView.functions.js @@ -0,0 +1,136 @@ +import { Platform } from 'react-native' + +import Minilog from 'cozy-minilog' + +import { checkOauthClientsLimit } from '/app/domain/limits/checkOauthClientsLimit' +import { showOauthClientsLimitExceeded } from '/app/domain/limits/OauthClientsLimitService' +import { IndexInjectionWebviewComponent } from '/components/webviews/webViewComponents/IndexInjectionWebviewComponent' +import { updateCozyAppBundleInBackground } from '/libs/cozyAppBundle/cozyAppBundle' +import { getCookie } from '/libs/httpserver/httpCookieManager' + +const log = Minilog('CozyProxyWebView.functions') + +const NO_INJECTED_HTML = 'NO_INJECTED_HTML' + +export const initHtmlContent = async ({ + httpServerContext, + slug, + href, + client, + dispatch, + setHtmlContentCreationDate +}) => { + const cookieAlreadyExists = (await getCookie(client)) !== undefined + log.debug(`Check cookie already exists: ${cookieAlreadyExists}`) + + if ( + cookieAlreadyExists && + (await doesOauthClientsLimitPreventsLoading(client, slug, href)) + ) { + log.debug('Stop loading HTML because OAuth client limit is reached (pre)') + return + } + + const htmlContent = await httpServerContext.getIndexHtmlForSlug(slug, client) + + if ( + !cookieAlreadyExists && + (await doesOauthClientsLimitPreventsLoading(client, slug, href)) + ) { + log.debug('Stop loading HTML because OAuth client limit is reached (post)') + return + } + + const { source: sourceActual, nativeConfig: nativeConfigActual } = + getPlaformSpecificConfig(href, htmlContent || NO_INJECTED_HTML) + + setHtmlContentCreationDate(Date.now()) + dispatch(oldState => ({ + ...oldState, + html: htmlContent, + nativeConfig: nativeConfigActual, + source: sourceActual + })) + + updateCozyAppBundleInBackground({ + slug, + client + }) +} + +const getHttpUnsecureUrl = uri => { + if (uri) { + let httpUnsecureUrl = new URL(uri) + httpUnsecureUrl.protocol = 'http:' + + return httpUnsecureUrl + } + + return uri +} + +/** + * Retrieve the WebView's configuration for the current platform + * + * Android is not compatible with html/baseUrl injection as history would be broken + * + * So html/baseUrl injection is done only on iOS + * + * Instead, Android version is based on native WebView's ability to intercept queries + * and override the result. In this case we should use uri instead of html/baseUrl and + * declare a nativeConfig with IndexInjectionWebviewComponent + * + * @param {string} uri - the webView's URI + * @param {string} html - the HTML to inject as index.html + * @returns source and nativeConfig props to be set on the WebView + */ +const getPlaformSpecificConfig = (uri, html) => { + const httpUnsecureUrl = getHttpUnsecureUrl(uri) + + if (html === NO_INJECTED_HTML) { + return { + source: { uri }, + nativeConfig: undefined + } + } + + const source = + Platform.OS === 'ios' + ? { html, baseUrl: httpUnsecureUrl.toString() } + : { uri } + + const nativeConfig = + Platform.OS === 'ios' + ? undefined + : { component: IndexInjectionWebviewComponent } + + return { + source, + nativeConfig + } +} + +/** + * Checks if OauthClientLimit is reached and trigger the OauthClientsLimitExceeded limit if needed + * Also check if the WebView rendering should be prevented and returns the result + * + * @param {CozyClient} client - CozyClient instance + * @param {string} slug - The application slug + * @param {string} href - The WebView requested href + * @returns true if the WebView rendering should be prevented, false otherwise + */ +const doesOauthClientsLimitPreventsLoading = async (client, slug, href) => { + const isOauthClientsLimitExeeded = await checkOauthClientsLimit(client) + + if (isOauthClientsLimitExeeded) { + if (slug === 'home') { + showOauthClientsLimitExceeded(href) + return false + } else if (slug !== 'settings') { + showOauthClientsLimitExceeded(href) + return true + } + } + + return false +} diff --git a/src/components/webviews/CozyProxyWebView.js b/src/components/webviews/CozyProxyWebView.js index 2ec53361f..0204eebfb 100644 --- a/src/components/webviews/CozyProxyWebView.js +++ b/src/components/webviews/CozyProxyWebView.js @@ -1,116 +1,21 @@ -import Minilog from 'cozy-minilog' import { useFocusEffect } from '@react-navigation/native' import React, { useCallback, useState, useEffect } from 'react' -import { AppState, Platform, View } from 'react-native' +import { AppState, View } from 'react-native' import { useClient } from 'cozy-client' +import Minilog from 'cozy-minilog' -import { styles } from './CozyProxyWebView.styles' -import { CozyWebView } from './CozyWebView' - -import { checkOauthClientsLimit } from '/app/domain/limits/checkOauthClientsLimit' -import { showOauthClientsLimitExceeded } from '/app/domain/limits/OauthClientsLimitService' import { RemountProgress } from '/app/view/Loading/RemountProgress' -import { updateCozyAppBundleInBackground } from '/libs/cozyAppBundle/cozyAppBundle' +import { initHtmlContent } from '/components/webviews/CozyProxyWebView.functions' +import { CozyWebView } from '/components/webviews/CozyWebView' import { useHttpServerContext } from '/libs/httpserver/httpServerProvider' -import { IndexInjectionWebviewComponent } from '/components/webviews/webViewComponents/IndexInjectionWebviewComponent' -const log = Minilog('CozyProxyWebView') +import { styles } from '/components/webviews/CozyProxyWebView.styles' -const NO_INJECTED_HTML = 'NO_INJECTED_HTML' +const log = Minilog('CozyProxyWebView') const HTML_CONTENT_EXPIRATION_DELAY_IN_MS = 23 * 60 * 60 * 1000 -const getHttpUnsecureUrl = uri => { - if (uri) { - let httpUnsecureUrl = new URL(uri) - httpUnsecureUrl.protocol = 'http:' - - return httpUnsecureUrl - } - - return uri -} - -/** - * Retrieve the WebView's configuration for the current platform - * - * Android is not compatible with html/baseUrl injection as history would be broken - * - * So html/baseUrl injection is done only on iOS - * - * Instead, Android version is based on native WebView's ability to intercept queries - * and override the result. In this case we should use uri instead of html/baseUrl and - * declare a nativeConfig with IndexInjectionWebviewComponent - * - * @param {string} uri - the webView's URI - * @param {string} html - the HTML to inject as index.html - * @returns source and nativeConfig props to be set on the WebView - */ -const getPlaformSpecificConfig = (uri, html) => { - const httpUnsecureUrl = getHttpUnsecureUrl(uri) - - if (html === NO_INJECTED_HTML) { - return { - source: { uri }, - nativeConfig: undefined - } - } - - const source = - Platform.OS === 'ios' - ? { html, baseUrl: httpUnsecureUrl.toString() } - : { uri } - - const nativeConfig = - Platform.OS === 'ios' - ? undefined - : { component: IndexInjectionWebviewComponent } - - return { - source, - nativeConfig - } -} - -const initHtmlContent = async ({ - httpServerContext, - slug, - href, - client, - dispatch, - setHtmlContentCreationDate -}) => { - const isOauthClientsLimitExeeded = await checkOauthClientsLimit(client) - - if (isOauthClientsLimitExeeded) { - if (slug === 'home') { - showOauthClientsLimitExceeded(href) - } else if (slug !== 'settings') { - showOauthClientsLimitExceeded(href) - return - } - } - - const htmlContent = await httpServerContext.getIndexHtmlForSlug(slug, client) - - const { source: sourceActual, nativeConfig: nativeConfigActual } = - getPlaformSpecificConfig(href, htmlContent || NO_INJECTED_HTML) - - setHtmlContentCreationDate(Date.now()) - dispatch(oldState => ({ - ...oldState, - html: htmlContent, - nativeConfig: nativeConfigActual, - source: sourceActual - })) - - updateCozyAppBundleInBackground({ - slug, - client - }) -} - export const CozyProxyWebView = ({ slug, href, diff --git a/src/components/webviews/CozyProxyWebView.spec.js b/src/components/webviews/CozyProxyWebView.spec.js new file mode 100644 index 000000000..9a4808230 --- /dev/null +++ b/src/components/webviews/CozyProxyWebView.spec.js @@ -0,0 +1,238 @@ +import { checkOauthClientsLimit } from '/app/domain/limits/checkOauthClientsLimit' +import { showOauthClientsLimitExceeded } from '/app/domain/limits/OauthClientsLimitService' +import { initHtmlContent } from '/components/webviews/CozyProxyWebView.functions' +import { getCookie } from '/libs/httpserver/httpCookieManager' + +jest.mock('/app/domain/limits/checkOauthClientsLimit', () => ({ + checkOauthClientsLimit: jest.fn() +})) +jest.mock('/app/domain/limits/OauthClientsLimitService', () => ({ + showOauthClientsLimitExceeded: jest.fn() +})) +jest.mock('/libs/cozyAppBundle/cozyAppBundle', () => ({ + updateCozyAppBundleInBackground: jest.fn() +})) +jest.mock('/libs/httpserver/httpCookieManager', () => ({ + getCookie: jest.fn() +})) + +describe('CozyWebview', () => { + beforeEach(() => { + getCookie.mockResolvedValue(undefined) + checkOauthClientsLimit.mockResolvedValue(false) + }) + + describe('OAuth Client Limit', () => { + const httpServerContext = { + getIndexHtmlForSlug: jest.fn() + } + const href = 'https://claude-home.mycozy.cloud' + const client = {} + const dispatch = jest.fn() + const setHtmlContentCreationDate = jest.fn() + + describe('When rendering a cozy-app', () => { + it('Should load the WebView if OauthClientLimit is not reached', async () => { + const slug = 'drive' + + await initHtmlContent({ + httpServerContext, + slug, + href, + client, + dispatch, + setHtmlContentCreationDate + }) + + expect(showOauthClientsLimitExceeded).not.toHaveBeenCalled() + expect(httpServerContext.getIndexHtmlForSlug).toHaveBeenCalled() + expect(dispatch).toHaveBeenCalled() + }) + + it('Should stop loading WebView and show OauthClientLimitExceeded popup if OauthClientLimit is reached', async () => { + getCookie.mockResolvedValue({ name: 'SOME_COOKIE' }) + checkOauthClientsLimit.mockResolvedValue(true) + + const slug = 'drive' + + await initHtmlContent({ + httpServerContext, + slug, + href, + client, + dispatch, + setHtmlContentCreationDate + }) + + expect(checkOauthClientsLimit).toHaveBeenCalledTimes(1) + expect(showOauthClientsLimitExceeded).toHaveBeenCalled() + expect(httpServerContext.getIndexHtmlForSlug).not.toHaveBeenCalled() + expect(dispatch).not.toHaveBeenCalled() + }) + }) + + describe('When rendering cozy-home', () => { + it('Should load the WebView if OauthClientLimit is not reached', async () => { + const slug = 'home' + + await initHtmlContent({ + httpServerContext, + slug, + href, + client, + dispatch, + setHtmlContentCreationDate + }) + + expect(showOauthClientsLimitExceeded).not.toHaveBeenCalled() + expect(httpServerContext.getIndexHtmlForSlug).toHaveBeenCalled() + expect(dispatch).toHaveBeenCalled() + }) + + it('Should load the WebView but show OauthClientLimitExceeded popup if OauthClientLimit is reached', async () => { + getCookie.mockResolvedValue({ name: 'SOME_COOKIE' }) + checkOauthClientsLimit.mockResolvedValue(true) + + const slug = 'home' + + await initHtmlContent({ + httpServerContext, + slug, + href, + client, + dispatch, + setHtmlContentCreationDate + }) + + expect(showOauthClientsLimitExceeded).toHaveBeenCalledWith( + 'https://claude-home.mycozy.cloud' + ) + expect(httpServerContext.getIndexHtmlForSlug).toHaveBeenCalled() + expect(dispatch).toHaveBeenCalled() + }) + }) + + describe('When rendering cozy-settings', () => { + it('Should load the WebView if OauthClientLimit is not reached', async () => { + const slug = 'settings' + + await initHtmlContent({ + httpServerContext, + slug, + href, + client, + dispatch, + setHtmlContentCreationDate + }) + + expect(showOauthClientsLimitExceeded).not.toHaveBeenCalled() + expect(httpServerContext.getIndexHtmlForSlug).toHaveBeenCalled() + expect(dispatch).toHaveBeenCalled() + }) + + it('Should load the WebView without showing OauthClientLimitExceeded popup if OauthClientLimit is reached', async () => { + getCookie.mockResolvedValue({ name: 'SOME_COOKIE' }) + checkOauthClientsLimit.mockResolvedValue(true) + + const slug = 'settings' + + await initHtmlContent({ + httpServerContext, + slug, + href, + client, + dispatch, + setHtmlContentCreationDate + }) + + expect(showOauthClientsLimitExceeded).not.toHaveBeenCalledWith() + expect(httpServerContext.getIndexHtmlForSlug).toHaveBeenCalled() + expect(dispatch).toHaveBeenCalled() + }) + }) + + describe('Should handle Cookie race-condition', () => { + it('Should call checkOauthClientsLimit once if Cookie is set', async () => { + getCookie.mockResolvedValue({ name: 'SOME_COOKIE' }) + checkOauthClientsLimit.mockResolvedValue(false) + + const slug = 'home' + + await initHtmlContent({ + httpServerContext, + slug, + href, + client, + dispatch, + setHtmlContentCreationDate + }) + + expect(checkOauthClientsLimit).toHaveBeenCalledTimes(1) + expect(httpServerContext.getIndexHtmlForSlug).toHaveBeenCalled() + expect(dispatch).toHaveBeenCalled() + }) + + it('Should call checkOauthClientsLimit once if no Cookie is set', async () => { + getCookie.mockResolvedValue(undefined) + checkOauthClientsLimit.mockResolvedValue(false) + + const slug = 'home' + + await initHtmlContent({ + httpServerContext, + slug, + href, + client, + dispatch, + setHtmlContentCreationDate + }) + + expect(checkOauthClientsLimit).toHaveBeenCalledTimes(1) + expect(httpServerContext.getIndexHtmlForSlug).toHaveBeenCalled() + expect(dispatch).toHaveBeenCalled() + }) + + it('Should stop loading WebView before calling getIndexHtmlForSlug if Cookie is set and OauthClientLimit is reached', async () => { + getCookie.mockResolvedValue({ name: 'SOME_COOKIE' }) + checkOauthClientsLimit.mockResolvedValue(true) + + const slug = 'drive' + + await initHtmlContent({ + httpServerContext, + slug, + href, + client, + dispatch, + setHtmlContentCreationDate + }) + + expect(checkOauthClientsLimit).toHaveBeenCalledTimes(1) + expect(showOauthClientsLimitExceeded).toHaveBeenCalled() + expect(httpServerContext.getIndexHtmlForSlug).not.toHaveBeenCalled() + expect(dispatch).not.toHaveBeenCalled() + }) + + it('Should stop loading WebView after calling getIndexHtmlForSlug if no Cookie is set and OauthClientLimit is reached', async () => { + getCookie.mockResolvedValue(undefined) + checkOauthClientsLimit.mockResolvedValue(true) + + const slug = 'drive' + + await initHtmlContent({ + httpServerContext, + slug, + href, + client, + dispatch, + setHtmlContentCreationDate + }) + + expect(checkOauthClientsLimit).toHaveBeenCalledTimes(1) + expect(showOauthClientsLimitExceeded).toHaveBeenCalled() + expect(httpServerContext.getIndexHtmlForSlug).toHaveBeenCalled() + expect(dispatch).not.toHaveBeenCalled() + }) + }) + }) +})