diff --git a/src/index.jsx b/src/index.jsx index 99b278cd2..16d76aede 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -142,37 +142,38 @@ const getUserActionRequired = () => { return undefined } -const determineSSL = (ssl, cozyURL) => { - if (typeof ssl !== 'undefined') return ssl - - let parsedURL - try { - parsedURL = new URL(cozyURL) - return parsedURL.protocol === 'https:' - } catch (error) { - console.warn( - `cozyURL parameter passed to Cozy-bar is not a valid URL (${ - error.message - }). Cozy-bar will rely on window.location to detect SSL.` - ) - } - - if (window && window.location && window.location.protocol) { - return window.location.protocol === 'https:' - } - - console.warn('Cozy-bar cannot detect SSL and will use default value (true)') - return true -} - let exposedAPI = {} +/** + * Initializes the cozy bar + * + * It can be initialized either with a cozyClient instance + * or a { cozyURL, ssl, token } tupple. + * + * @function + * @param {Object} arg + * @param {string} arg.appName - App name to be displayed in the bar + * @param {string} arg.appNamePrefix + * @param {string} arg.lang - Language for the bar + * @param {string} arg.iconPath - + * @param {Object} arg.cozyClient - a cozy client instance + * @param {string} arg.cozyURL - URL or domain of the stack + * @param {boolean} arg.ssl - Tells if we should use a secure + * protocol required if cozyURL does + * not have a protocol + * @param {string} arg.token - Access token for the stack + * @param {boolean} arg.replaceTitleOnMobile + * @param {boolean} arg.isPublic + * @param {Function} arg.onLogout + * @param {Function} arg.onDeleteApp + */ const init = async ({ appName, appNamePrefix = getAppNamePrefix(), appSlug = getAppSlug(), lang, iconPath = getDefaultIcon(), + cozyClient, cozyURL = getDefaultStackURL(), token = getDefaultToken(), replaceTitleOnMobile = false, @@ -190,11 +191,12 @@ const init = async ({ reduxStore.dispatch(setInfos(appName, appNamePrefix, appSlug)) stack.init({ + cozyClient, cozyURL, token, onCreateApp: app => reduxStore.dispatch(receiveApp(app)), onDeleteApp: app => reduxStore.dispatch(deleteApp(app)), - ssl: determineSSL(ssl, cozyURL) + ssl }) if (lang) { reduxStore.dispatch(setLocale(lang)) diff --git a/src/lib/icon.js b/src/lib/icon.js index 4fede91f2..5625d6264 100644 --- a/src/lib/icon.js +++ b/src/lib/icon.js @@ -9,9 +9,20 @@ const mimeTypes = { svg: 'image/svg+xml' } +/** + * Get an icon URL usable in the HTML page from it's stack path + * + * @function + * @private + * @param {function} iconFetcher - takes an icon path on the stack + * and returns a fetch response with the icon + * @param {object} app - app object with a `links.icon` attribute + * @param {boolean} useCache + * @returns {Promise} url string of an icon usable in the HTML page + * may be empty if the `app` object didn't have an icon path + */ module.exports = async function getIcon( - cozyUrl, - fetchHeaders, + iconFetcher, app = {}, useCache = true ) { @@ -22,7 +33,7 @@ module.exports = async function getIcon( let icon try { - const resp = await fetch(`${cozyUrl}${url}`, fetchHeaders) + const resp = await iconFetcher(url) if (!resp.ok) throw new Error(`Error while fetching icon ${resp.statusText}: ${url}`) icon = await resp.blob() diff --git a/src/lib/normalize-url.js b/src/lib/normalize-url.js new file mode 100644 index 000000000..1ba388095 --- /dev/null +++ b/src/lib/normalize-url.js @@ -0,0 +1,47 @@ + +/** + * Gives an URL object from a partial URL + * + * Mobile apps give us full URL with domain and protocol. + * Web apps may also initialize with a full URL + * but they usually don't, and we the use the + * domain given by the stack through a DOM node + * in the HTML page (despite it not being a full URL). + * + * This function normalizes these different cases + * and returns a full URL object. + * + * The `secure` parameter, if given, always has the + * priority over the one from the URL. + * We use a secure protocol by default if we can't decide. + * + * @param {String} cozyURL - full URL or domain of the cozy + * @param {boolean} secure - optional, force the protocol to use ssl (or not) + * @returns {URL} + */ +export default function normalizeURL(cozyURL, secure) { + if (typeof cozyURL === 'undefined') { + throw new Error("Please give the Cozy URL when initializing the cozy-bar") + } + + if (cozyURL.match(/:\/\//)) { + // This is a full URL, probably received from a mobile app. + // The secure parameter overrides the protocol. + if (secure !== undefined) { + cozyURL = cozyURL.replace(/^https?:/, secure ? 'https:' : 'http:') + } + } else { + // We only have the domain, not a real URL. + // Let's decide what protocol we should use. + if (secure === undefined) { + if (window && window.location && window.location.protocol) { + secure = (window.location.protocol === 'https:') + } else { + secure = true + } + } + cozyURL = (secure ? 'https://' : 'http://') + cozyURL + } + return new URL(cozyURL) +} + diff --git a/src/lib/realtime.js b/src/lib/realtime.js new file mode 100644 index 000000000..0e12d17d3 --- /dev/null +++ b/src/lib/realtime.js @@ -0,0 +1,40 @@ +import realtime from 'cozy-realtime' + + +/** + * Initialize realtime sockets + * + * @private + * @param {object} + * @returns {Promise} + */ +async function initializeRealtime({ getApp, onCreateApp, onDeleteApp, url, token }) { + const realtimeConfig = { token, url } + + try { + realtime + .subscribe(realtimeConfig, 'io.cozy.apps') + .onCreate(async app => { + // Fetch directly the app to get attributes `related` as well. + let fullApp + try { + fullApp = await getApp(app.slug) + } catch (error) { + throw new Error(`Cannot fetch app ${app.slug}: ${error.message}`) + } + + if (typeof onCreateApp === 'function') { + onCreateApp(fullApp) + } + }) + .onDelete(app => { + if (typeof onDeleteApp === 'function') { + onDeleteApp(app) + } + }) + } catch (error) { + console.warn(`Cannot initialize realtime in Cozy-bar: ${error.message}`) + } +} + +export default initializeRealtime diff --git a/src/lib/stack-client.js b/src/lib/stack-client.js new file mode 100644 index 000000000..208458af2 --- /dev/null +++ b/src/lib/stack-client.js @@ -0,0 +1,366 @@ +/* global __TARGET__ */ +/* eslint-env browser */ + +import getIcon from 'lib/icon' +import initializeRealtime from 'lib/realtime' + +import { + ForbiddenException, + ServerErrorException, + NotFoundException, + MethodNotAllowedException, + UnavailableStackException, + UnauthorizedStackException +} from './exceptions' + +const errorStatuses = { + '401': UnauthorizedStackException, + '403': ForbiddenException, + '404': NotFoundException, + '405': MethodNotAllowedException, + '500': ServerErrorException +} + +/** + * Cozy client instance + * @private + */ +let cozyClient + +/** + * Get the stackClient from the cozy-client instance + * + * @private + * @function + * @returns {Object} cozy-stack-client instance + */ +const getStackClient = function() { + return cozyClient.getStackClient() +} + +/** + * Logout and disconnect the user + * @function + * @TODO move this to cozy-stack-client + * @returns {Promise} + */ +const logout = function() { + return getStackClient().fetch('DELETE', '/auth/login').then( + resp => { + if (resp.status === 401) { + throw new UnauthorizedStackException() + } else if (resp.status === 204) { + window.location.reload() + } + return true + } + ).catch( + () => { + throw new UnavailableStackException() + } + ) +} + +/** + * Get a cozy URL object + * + * @function + * @returns {URL} + */ +const getCozyURL = function() { + return new URL(getStackClient().uri) +} + +/** + * Get a the cozy origin as an URL string + * + * @function + * @returns {string} + */ +const getCozyURLOrigin = function() { + return getCozyURL().origin +} + +/** + * @deprecated + * @private + */ +const updateAccessToken = function(token) { + throw new Error("updateAccessToken should not be used with a cozy-client instance initialization") +} + +/** + * Fetch a resource with cozy-client + * + * Utility to maintain the compatibility with the legacy + * standalone cozy-bar client + * + * @function + * @private + * @returns {Promise} the full raw JSON payload + */ +const fetchJSON = function(method, path, body, options={}) { + + // We mirror here a few lines from cozy-stack-client + // because we want a customized fetchJSON + const headers = (options.headers = options.headers || {}) + headers['Accept'] = 'application/json' + if (method !== 'GET' && method !== 'HEAD' && body !== undefined) { + if (!headers['Content-Type']) { + headers['Content-Type'] = 'application/json' + body = JSON.stringify(body) + } + } + + return getStackClient().fetch(method, path, body, options).then( + resp => { + if (typeof errorStatuses[resp.status] === 'function') { + throw new errorStatuses[resp.status]() + } + const contentType = resp.headers.get('content-type') + const isJson = contentType.includes('json') + if (!isJson) { + throw new Error("Server response not in JSON") + } + return resp.json() + } + ) +} + +/** + * Test if an error is from an HTTP 404 + * + * @function + * @private + * @param {Function} error - received from a fetch + * @returns {boolean} + */ +const is404 = function(error) { + return ['NotFoundException', 'NotFound', 'FetchError'].includes(error.name) && + error.status && + error.status === 404 +} + +/** + * Memoize the result of a function which does an HTTP fetch + * + * If a call throws an error because the + * underlying HTTP request returned a 404 + * then this function returns a default value + * + * In the absence of any other error, the result is + * cached and reused in the next call to the function. + * + * + * @function + * @param {Function} fn - the function to memoize. It will be + * called without any parameter + * @param {Object} defaultValue - returned in case of 404 + * @returns {Function} async function + */ +const withCache = function(fn, defaultValue) { + let cache = undefined + return async function() { + if (cache === undefined) { + try { + cache = await fn() + } catch(error) { + cache = is404(error) ? defaultValue : undefined + } + } + return cache + } +} + +/** + * List all installed applications + * + * Returns only the `data` key of the + * whole JSON payload from the server + * + * @function + * @returns {Promise} + */ +const getApps = function () { + return fetchJSON('GET', '/apps/').then( + json => { + if (json.error) { + throw new Error(json.error) + } + return json.data + } + ) +} + +/** + * Detail of an installed application by its slug + * + * Returns only the `data` key of the + * whole JSON payload from the server + * + * @function + * @param {string} slug + * @returns {Promise} + */ +const getApp = function(slug) { + if (!slug) { + throw new Error('Missing slug') + } + return fetchJSON('GET', `/apps/${slug}`).then( + json => { + if (json.error) { + throw new Error(json.error) + } + return json.data + } + ) +} + +/** + * default value when no quota is provided + * @private + */ +const defaultQuota = 10**12 // 1 Tera + +/** + * Get storage and quota usage + * + * When no quota is returned by the server + * the quota used is the larger between + * `defaultQuota` and 10 * usage + * + * @function + * @returns {Object} {usage, quota, isLimited} + */ +const getStorageData = function() { + return fetchJSON('GET', '/settings/disk-usage').then( + json => { + // parseInt because responses from the server are in text + const usage = parseInt(json.data.attributes.used, 10) + const realQuota = parseInt(json.data.attributes.quota, 10) + // @TODO this is a workaround, we should certainly do smarter + // and either not requiring this attribute + // or set it to something more real + const quota = realQuota || Math.max(defaultQuota, 10 * usage) + const isLimited = json.data.attributes.is_limited + return { usage, quota, isLimited } + } + ).catch( + error => { + throw new UnavailableStackException() + } + ) +} + +/** + * Fetch an icon data from its path + * + * The purpose of this function is to be sent + * to AppIcon components for mobile devices. + * + * @private + * @function + * @param {string} iconPath - path of the icon in the stack + * @returns {Blob} + */ +const iconFetcher = function(iconPath) { + return getStackClient().fetch('GET', iconPath) +} + +/** + * Get a props object that can be sent to an AppIcon component + * + * Mobile devices and web browsers need different props + * + * @function + * @returns {Object} + */ +const getAppIconProps = function() { + const isMobile = (__TARGET__ === 'mobile') + + const mobileAppIconProps = { + fetchIcon: app => getIcon(iconFetcher, app, true) + } + + const browserAppIconProps = { + // we mustn't give the protocol here + domain: getCozyURL().host, + secure: (getCozyURL().protocol === 'https:') + } + + return isMobile ? mobileAppIconProps : browserAppIconProps +} + +/** + * Get settings context + * + * @function + * @return {Promise} + * @see https://docs.cozy.io/en/cozy-stack/settings/#get-settingscontext + */ +const getContext = function() { + return fetchJSON('GET', '/settings/context') +} + +/** + * Fetch a resource on the cozy stack + * with a prototype compatible with the legacy cozy-client-js + * + * @function + * @param {object} cozy - cozy-client-js + * @param {string} method - HTTP method + * @param {string} path + * @param {object} body + * @returns {Promise} + */ +const cozyFetchJSON = function(cozy, method, path, body) { + return fetchJSON(method, path, body).then( + json => { + const responseData = Object.assign({}, json.data) + if (responseData.id) { + responseData._id = responseData.id + } + return responseData + } + ) +} + +/** + * Initializes the functions to call the cozy stack + * + * @function + * @param {Object} arg + * @param {Object} arg.cozyClient - a cozy client instance + * @param {Function} arg.onCreateApp + * @param {Function} arg.onDeleteApp + * @returns {Promise} + */ +const init = function({ + cozyClient: client, + onCreateApp, + onDeleteApp +}) { + cozyClient = client + return initializeRealtime({ + getApp, + onCreateApp, + onDeleteApp, + token: getStackClient().token.token, + url: getCozyURLOrigin(), + }) +} + +export default { + get: { + app: getApp, + apps: getApps, + context: withCache(getContext, {}), + storageData: getStorageData, + iconProps: getAppIconProps, + cozyURL: getCozyURLOrigin + }, + updateAccessToken, + cozyFetchJSON, + logout, + init  +} \ No newline at end of file diff --git a/src/lib/stack-internal.js b/src/lib/stack-internal.js new file mode 100644 index 000000000..43894f584 --- /dev/null +++ b/src/lib/stack-internal.js @@ -0,0 +1,201 @@ +/* global __TARGET__ */ +/* eslint-env browser */ + +import getIcon from './icon' +import initializeRealtime from './realtime' +import normalizeURL from './normalize-url' + +import { + ForbiddenException, + ServerErrorException, + NotFoundException, + MethodNotAllowedException, + UnavailableStackException, + UnavailableSettingsException, + UnauthorizedStackException +} from './exceptions' + +// the option credentials:include tells fetch to include the cookies in the +// request even for cross-origin requests +function fetchOptions() { + return { + credentials: 'include', + headers: { + Authorization: `Bearer ${COZY_TOKEN}`, + Accept: 'application/json' + } + } +} + +let COZY_URL +let COZY_HOST +let COZY_TOKEN +let USE_SSL + +const errorStatuses = { + '401': UnauthorizedStackException, + '403': ForbiddenException, + '404': NotFoundException, + '405': MethodNotAllowedException, + '500': ServerErrorException +} + +function getApps() { + return fetchJSON(`${COZY_URL}/apps/`, fetchOptions()).then(json => { + if (json.error) throw new Error(json.error) + else return json.data + }) +} + +function fetchJSON(url, options) { + return fetch(url, options).then(res => { + if (typeof errorStatuses[res.status] === 'function') { + throw new errorStatuses[res.status]() + } + + return res.json() + }) +} + +// fetch function with the same interface than in cozy-client-js +function cozyFetchJSON(cozy, method, path, body) { + const requestOptions = Object.assign({}, fetchOptions(), { + method + }) + requestOptions.headers['Accept'] = 'application/json' + if (method !== 'GET' && method !== 'HEAD' && body !== undefined) { + if (requestOptions.headers['Content-Type']) { + requestOptions.body = body + } else { + requestOptions.headers['Content-Type'] = 'application/json' + requestOptions.body = JSON.stringify(body) + } + } + return fetchJSON(`${COZY_URL}${path}`, requestOptions).then(json => { + const responseData = Object.assign({}, json.data) + if (responseData.id) responseData._id = responseData.id + return Promise.resolve(responseData) + }) +} + +function getStorageData() { + return fetchJSON(`${COZY_URL}/settings/disk-usage`, fetchOptions()) + .then(json => { + return { + usage: parseInt(json.data.attributes.used, 10), + // TODO Better handling when no quota provided + quota: parseInt(json.data.attributes.quota, 10) || 100000000000, + isLimited: json.data.attributes.is_limited + } + }) + .catch(() => { + throw new UnavailableStackException() + }) +} + +function getContext(cache) { + return () => { + return cache['context'] + ? Promise.resolve(cache['context']) + : fetchJSON(`${COZY_URL}/settings/context`, fetchOptions()) + .then(context => { + cache['context'] = context + return context + }) + .catch(error => { + if (error.status && error.status === 404) cache['context'] = {} + }) + } +} + +function getApp(slug) { + if (!slug) { + throw new Error('Missing slug') + } + return fetchJSON(`${COZY_URL}/apps/${slug}`, fetchOptions()).then(json => { + if (json.error) throw new Error(json.error) + else return json.data + }) +} + +const cache = {} + +/** + * Fetch an icon from the stack by it's path + * + * @function + * @private + * @param {string} iconPath - path of the icon + * @returns {Promise} Fetch response + */ +const _iconFetcher = function(iconPath) { + return fetch(COZY_URL + iconPath, fetchOptions()) +} + +const _fetchIcon = app => getIcon(_iconFetcher, app, true) +export const getAppIconProps = () => { + return __TARGET__ === 'mobile' + ? { fetchIcon: _fetchIcon } + : { + // we mustn't give the protocol here + domain: COZY_HOST, + secure: USE_SSL + } +} + +module.exports = { + async init({ cozyURL, token, onCreateApp, onDeleteApp, ssl }) { + const url = normalizeURL(cozyURL, ssl) + // The 4 following constant are global variables for the module + COZY_URL = url.origin + COZY_HOST = url.host + USE_SSL = (url.protocol === 'https:') + COZY_TOKEN = token + await initializeRealtime({ + getApp, + onCreateApp, + onDeleteApp, + token: COZY_TOKEN, + url: COZY_URL + }) + }, + updateAccessToken(token) { + COZY_TOKEN = token + }, + get: { + app: getApp, + apps: getApps, + context: getContext(cache), + storageData: getStorageData, + iconProps: getAppIconProps, + cozyURL() { + return COZY_URL + }, + settingsAppURL() { + return getApp('settings').then(settings => { + if (!settings) { + throw new UnavailableSettingsException() + } + return settings.links.related + }) + } + }, + logout() { + const options = Object.assign({}, fetchOptions(), { + method: 'DELETE' + }) + + return fetch(`${COZY_URL}/auth/login`, options) + .then(res => { + if (res.status === 401) { + throw new UnauthorizedStackException() + } else if (res.status === 204) { + window.location.reload() + } + }) + .catch(() => { + throw new UnavailableStackException() + }) + }, + cozyFetchJSON // used in intents library +} diff --git a/src/lib/stack.js b/src/lib/stack.js index 338f47a4b..c59e76bde 100644 --- a/src/lib/stack.js +++ b/src/lib/stack.js @@ -1,231 +1,70 @@ -/* global __TARGET__ */ -/* eslint-env browser */ - -import realtime from 'cozy-realtime' -import getIcon from './icon' - -import { - ForbiddenException, - ServerErrorException, - NotFoundException, - MethodNotAllowedException, - UnavailableStackException, - UnavailableSettingsException, - UnauthorizedStackException -} from './exceptions' - -// the option credentials:include tells fetch to include the cookies in the -// request even for cross-origin requests -function fetchOptions() { - return { - credentials: 'include', - headers: { - Authorization: `Bearer ${COZY_TOKEN}`, - Accept: 'application/json' - } - } -} - -let COZY_URL -let COZY_HOST -let COZY_TOKEN -let USE_SSL - -const errorStatuses = { - '401': UnauthorizedStackException, - '403': ForbiddenException, - '404': NotFoundException, - '405': MethodNotAllowedException, - '500': ServerErrorException -} - -function getApps() { - return fetchJSON(`${COZY_URL}/apps/`, fetchOptions()).then(json => { - if (json.error) throw new Error(json.error) - else return json.data - }) -} - -function fetchJSON(url, options) { - return fetch(url, options).then(res => { - if (typeof errorStatuses[res.status] === 'function') { - throw new errorStatuses[res.status]() - } - - return res.json() - }) -} - -// fetch function with the same interface than in cozy-client-js -function cozyFetchJSON(cozy, method, path, body) { - const requestOptions = Object.assign({}, fetchOptions(), { - method - }) - requestOptions.headers['Accept'] = 'application/json' - if (method !== 'GET' && method !== 'HEAD' && body !== undefined) { - if (requestOptions.headers['Content-Type']) { - requestOptions.body = body - } else { - requestOptions.headers['Content-Type'] = 'application/json' - requestOptions.body = JSON.stringify(body) - } - } - return fetchJSON(`${COZY_URL}${path}`, requestOptions).then(json => { - const responseData = Object.assign({}, json.data) - if (responseData.id) responseData._id = responseData.id - return Promise.resolve(responseData) - }) -} - -function getStorageData() { - return fetchJSON(`${COZY_URL}/settings/disk-usage`, fetchOptions()) - .then(json => { - return { - usage: parseInt(json.data.attributes.used, 10), - // TODO Better handling when no quota provided - quota: parseInt(json.data.attributes.quota, 10) || 100000000000, - isLimited: json.data.attributes.is_limited - } - }) - .catch(() => { - throw new UnavailableStackException() - }) -} - -function getContext(cache) { - return () => { - return cache['context'] - ? Promise.resolve(cache['context']) - : fetchJSON(`${COZY_URL}/settings/context`, fetchOptions()) - .then(context => { - cache['context'] = context - return context - }) - .catch(error => { - if (error.status && error.status === 404) cache['context'] = {} - }) +import internal from 'lib/stack-internal.js' +import client from "lib/stack-client" + + +/** + * Reference to the current client depending + * on which one has been initialized + * + * @private + * @TODO We should set it to `undefined` + * and throw an error when it is not initialized + * Leaving with that default for today + * so I do not need to rewrite deeply the tests + * (they test some components without initializing + * the stack client first, and rely on non-initialized + * legacy client behaviour) + */ +let stack = internal + +/** + * Get the current stack client (legacy or cozy-client based) + * based on which one has been initialized + * + * @returns {Object} functions to call the stack + */ +const current = function() { + if (stack === undefined) { + throw new Error("client not initialized in cozy-bar") } + return stack } -function getApp(slug) { - if (!slug) { - throw new Error('Missing slug') - } - return fetchJSON(`${COZY_URL}/apps/${slug}`, fetchOptions()).then(json => { - if (json.error) throw new Error(json.error) - else return json.data - }) +/** + * Initializes the functions to call the cozy stack + * + * It can be initialized either with a cozy-client instance + * or a { cozyURL, ssl, token } tupple. + * + * @function + * @param {Object} arg + * @param {Object} arg.cozyClient - a cozy client instance + * @param {string} arg.cozyURL - URL or domain of the stack + * @param {boolean} arg.ssl - Tells if we should use a secure protocol + * required if cozyURL does not have a protocol + * @param {string} arg.token - Access token for the stack + * @param {Function} arg.onCreateApp + * @param {Function} arg.onDeleteApp + * @returns {Promise} + */ +const init = function(options) { + stack = (options.cozyClient) ? client : internal + return stack.init(options) +} + +const get = { + app: (...args) => current().get.app(...args), + apps: (...args) => current().get.apps(...args), + context: (...args) => current().get.context(...args), + storageData: (...args) => current().get.storageData(...args), + iconProps: (...args) => current().get.iconProps(...args), + cozyURL: (...args) => current().get.cozyURL(...args), } -const cache = {} - -const _fetchIcon = app => getIcon(COZY_URL, fetchOptions(), app, true) -export const getAppIconProps = () => { - return __TARGET__ === 'mobile' - ? { fetchIcon: _fetchIcon } - : { - // we mustn't give the protocol here - domain: COZY_HOST, - secure: USE_SSL - } -} - -async function initializeRealtime({ onCreateApp, onDeleteApp, url, token }) { - const realtimeConfig = { token, url } - - try { - realtime - .subscribe(realtimeConfig, 'io.cozy.apps') - .onCreate(async app => { - // Fetch direclty the app to get attributes `related` as well. - let fullApp - try { - fullApp = await getApp(app.slug) - } catch (error) { - throw new Error(`Cannont fetch app ${app.slug}: ${error.message}`) - } - - if (typeof onCreateApp === 'function') { - onCreateApp(fullApp) - } - }) - .onDelete(app => { - if (typeof onDeleteApp === 'function') { - onDeleteApp(app) - } - }) - } catch (error) { - console.warn(`Cannot initialize realtime in Cozy-bar: ${error.message}`) - } -} - -const determineURL = (cozyURL, ssl) => { - let url - let host - const protocol = ssl ? 'https' : 'http' - - try { - // only on mobile we get the full URL with the protocol - host = new URL(cozyURL).host - url = !!host && `${protocol}://${host}` - } catch (e) {} // eslint-disable-line no-empty - - host = host || cozyURL - url = url || `${protocol}://${host}` - - return { COZY_URL: url, COZY_HOST: host } -} - -module.exports = { - async init({ cozyURL, token, onCreateApp, onDeleteApp, ssl }) { - ;({ COZY_URL, COZY_HOST } = determineURL(cozyURL, ssl)) - COZY_TOKEN = token - USE_SSL = ssl - await initializeRealtime({ - onCreateApp, - onDeleteApp, - token: COZY_TOKEN, - url: COZY_URL, - ssl: USE_SSL - }) - }, - updateAccessToken(token) { - COZY_TOKEN = token - }, - get: { - app: getApp, - apps: getApps, - context: getContext(cache), - storageData: getStorageData, - iconProps: getAppIconProps, - cozyURL() { - return COZY_URL - }, - settingsAppURL() { - return getApp('settings').then(settings => { - if (!settings) { - throw new UnavailableSettingsException() - } - return settings.links.related - }) - } - }, - logout() { - const options = Object.assign({}, fetchOptions(), { - method: 'DELETE' - }) - - return fetch(`${COZY_URL}/auth/login`, options) - .then(res => { - if (res.status === 401) { - throw new UnauthorizedStackException() - } else if (res.status === 204) { - window.location.reload() - } - }) - .catch(() => { - throw new UnavailableStackException() - }) - }, - cozyFetchJSON // used in intents library +export default { + init, + get, + updateAccessToken: (...args) => current().updateAccessToken(...args), + logout: (...args) => current().logout(...args), + cozyFetchJSON: (...args) => current().cozyFetchJSON(...args) } diff --git a/test/lib/normalize-url.spec.js b/test/lib/normalize-url.spec.js new file mode 100644 index 000000000..b4cd6af2b --- /dev/null +++ b/test/lib/normalize-url.spec.js @@ -0,0 +1,58 @@ +import normalizeUrl from 'lib/normalize-url' + +let old_window ; + +const https_url = "https://test.mycozy.cloud/path/with?parameters=1" +const http_url = "http://test.mycozy.cloud/path/with?parameters=1" +const https_origin = "https://test.mycozy.cloud" +const http_origin = "http://test.mycozy.cloud" +const domain = "test.mycozy.cloud" +const https_app = "https://test-app.mycozy.cloud/path/with?parameters=1" +const http_app = "http://test-app.mycozy.cloud/path/with?parameters=1" + +describe('URL normalization', () => { + + beforeAll(() => { + old_window = global.window + global.window = Object.create(window); + delete global.window.location + }) + + beforeEach(() => { + global.window.location = undefined + }) + + afterAll(() => { + global.window = old_window + old_window = undefined + }) + + it('should accept a domain name with a secure parameter', () => { + expect( normalizeUrl(domain, true).origin ).toBe(https_origin) + }) + + it('default protocol should be the one of window.location', () => { + window.location = new URL(https_app) + expect( normalizeUrl(domain, undefined).origin ).toBe(https_origin) + window.location = new URL(http_app) + expect( normalizeUrl(domain, undefined).origin ).toBe(http_origin) + }) + + it('should accept a complete origin', () => { + expect( normalizeUrl(https_origin, undefined).origin ).toBe(https_origin) + }) + + it('should accept an unsecure protocol', () => { + expect( normalizeUrl(http_origin, undefined).origin ).toBe(http_origin) + }) + + it('should be able to override the protocol', () => { + expect( normalizeUrl(http_origin, true).origin ).toBe(https_origin) + expect( normalizeUrl(https_origin, false).origin ).toBe(http_origin) + }) + + it('should accept a full URL', () => { + expect( normalizeUrl(http_url, undefined).origin ).toBe(http_origin) + }) + +}) diff --git a/test/lib/stack-client/stack-client.appiconprops.spec.js b/test/lib/stack-client/stack-client.appiconprops.spec.js new file mode 100644 index 000000000..a46639e62 --- /dev/null +++ b/test/lib/stack-client/stack-client.appiconprops.spec.js @@ -0,0 +1,84 @@ +/* global __TARGET__ */ + +import stack from 'lib/stack-client' + +import internal from 'lib/stack-internal' + +let oldTarget + +describe("stack client", () => { + + describe("getAppIconProps", () => { + + const stackClient = { + token: { token: "mytoken"}, + uri: "https://test.mycozy.cloud", + } + + const cozyClient = { + getStackClient: () => stackClient + } + + const params = { + cozyClient, + onCreateApp: function() {}, + onDeleteApp: function() {}, + } + + beforeAll(async () => { + oldTarget = global.__TARGET__ + jest.spyOn(internal.get, 'iconProps').mockResolvedValue(undefined) + await stack.init(params) + }) + + afterAll(() => { + jest.restoreAllMocks() + global.__TARGET__ = oldTarget + }) + + it("should not forward to the old internal client", () => { + stack.get.iconProps() + expect( internal.get.iconProps ).not.toHaveBeenCalled() + }) + + describe("for target=browser", () => { + + beforeAll(() => { + global.__TARGET__ = "browser" + }) + + it("should have `domain` and `secure` set", () => { + const data = stack.get.iconProps() + expect( data.domain ).toBe("test.mycozy.cloud") + expect( data.secure ).toBe(true) + }) + + it("should not have a `fetchIcon` function", () => { + const data = stack.get.iconProps() + expect( data.fetchIcon ).toBe(undefined) + }) + + }) + + describe("for target=mobile", () => { + + beforeAll(() => { + global.__TARGET__ = "mobile" + }) + + it("should note have `domain` and `secure` set", () => { + const data = stack.get.iconProps() + expect( data.domain ).toBeUndefined() + expect( data.secure ).toBeUndefined() + }) + + it("should have a `fetchIcon` function set", () => { + const data = stack.get.iconProps() + expect( data.fetchIcon ).toBeInstanceOf(Function) + }) + + }) + + }) + +}) diff --git a/test/lib/stack-client/stack-client.cozyfetchjson.spec.js b/test/lib/stack-client/stack-client.cozyfetchjson.spec.js new file mode 100644 index 000000000..d9a47e8f3 --- /dev/null +++ b/test/lib/stack-client/stack-client.cozyfetchjson.spec.js @@ -0,0 +1,49 @@ +import stack from 'lib/stack-client' + +describe("stack client", () => { + + describe("cozyFetchJSON", () => { + + let json = jest.fn().mockReturnValue({ + data: { id: "myid" } + }) + let fetch = jest.fn().mockResolvedValue({ + status:200, + headers: { + get: (header) => 'application/json' + }, + json + }) + let cozyClient = { + getStackClient: () => { + return { + token: { token: "mytoken"}, + uri: "https://test.mycozy.cloud", + fetch + } + } + } + let params = { + cozyClient, + onCreateApp: function() {}, + onDeleteApp: function() {}, + } + + beforeAll(async () => { + await stack.init(params) + }) + + it("should transform `id` in `_id`", async () => { + const data = await stack.cozyFetchJSON('_', 'GET', '/path') + expect( data._id ).toBe('myid') + }) + + it("should call fetch from cozy-client", async () => { + fetch.mockClear() + const data = await stack.cozyFetchJSON('_', 'GET', '/path') + expect( fetch ).toHaveBeenCalled() + }) + + }) + +}) diff --git a/test/lib/stack-client/stack-client.cozyurl.spec.js b/test/lib/stack-client/stack-client.cozyurl.spec.js new file mode 100644 index 000000000..47087e90c --- /dev/null +++ b/test/lib/stack-client/stack-client.cozyurl.spec.js @@ -0,0 +1,44 @@ +import stack from 'lib/stack-client' + +import internal from 'lib/stack-internal' + +describe("stack client", () => { + + describe("cozyURL", () => { + + const stackClient = { + token: { token: "mytoken"}, + uri: "https://test.mycozy.cloud", + } + + const cozyClient = { + getStackClient: () => stackClient + } + + const params = { + cozyClient, + onCreateApp: function() {}, + onDeleteApp: function() {}, + } + + beforeAll(async () => { + jest.spyOn(internal.get, 'cozyURL').mockResolvedValue(undefined) + await stack.init(params) + }) + + afterAll(() => { + jest.restoreAllMocks() + }) + + it("should give back the origin of cozy-client", () => { + expect( stack.get.cozyURL() ).toBe("https://test.mycozy.cloud") + }) + + it("should not forward to the old internal client", () => { + stack.get.cozyURL() + expect( internal.get.cozyURL ).not.toHaveBeenCalled() + }) + + }) + +}) diff --git a/test/lib/stack-client/stack-client.getapp.spec.js b/test/lib/stack-client/stack-client.getapp.spec.js new file mode 100644 index 000000000..5e412ca96 --- /dev/null +++ b/test/lib/stack-client/stack-client.getapp.spec.js @@ -0,0 +1,83 @@ +import stack from 'lib/stack-client' + +import internal from 'lib/stack-internal' + +describe("stack client", () => { + + describe("getApp", () => { + + const slug = "testapp" + + const stackClient = { + token: { token: "mytoken"}, + uri: "https://test.mycozy.cloud", + fetch: jest.fn().mockResolvedValue({ + status: 200, + headers: { get: (h) => "application/json" }, + json: () => { + // example from https://docs.cozy.io/en/cozy-stack/apps/ + return { + "data": { + "id": "4cfbd8be-8968-11e6-9708-ef55b7c20863", + "type": "io.cozy.apps", + "meta": { + "rev": "2-bbfb0fc32dfcdb5333b28934f195b96a" + }, + "attributes": { + "name": "calendar", + "state": "ready", + "slug": "calendar" + }, + "links": { + "self": "/apps/calendar", + "icon": "/apps/calendar/icon", + "related": "https://calendar.alice.example.com/" + } + } + } + } + }) + } + + const cozyClient = { + getStackClient: () => stackClient + } + + const params = { + cozyClient, + onCreateApp: function() {}, + onDeleteApp: function() {}, + } + + beforeAll( async () => { + jest.spyOn(internal.get, 'app').mockResolvedValue(undefined) + await stack.init(params) + }) + + afterAll(() => { + jest.restoreAllMocks() + }) + + it("should not forward to the old internal client", async () => { + await stack.get.app(slug) + expect( internal.get.app ).not.toHaveBeenCalled() + }) + + it("should return the `data` content", async () => { + const data = await stack.get.app(slug) + expect( data.id ).toBe("4cfbd8be-8968-11e6-9708-ef55b7c20863") + }) + + it("should have called cozy-client", async () => { + await stack.get.app(slug) + expect( cozyClient.getStackClient().fetch ).toHaveBeenCalled() + }) + + it("should not throw", async () => { + expect( () => stack.get.app(slug) ).not.toThrow() + await expect( stack.get.app(slug) ).resolves.not.toBe(false) + }) + + }) + +}) diff --git a/test/lib/stack-client/stack-client.getapps.spec.js b/test/lib/stack-client/stack-client.getapps.spec.js new file mode 100644 index 000000000..7d99bf59a --- /dev/null +++ b/test/lib/stack-client/stack-client.getapps.spec.js @@ -0,0 +1,83 @@ +import stack from 'lib/stack-client' + +import internal from 'lib/stack-internal' + +describe("stack client", () => { + + describe("getApps", () => { + + const stackClient = { + token: { token: "mytoken"}, + uri: "https://test.mycozy.cloud", + fetch: jest.fn().mockResolvedValue( + { + status: 200, + headers: { get: (h) => "application/json" }, + json: () => { + // example from https://docs.cozy.io/en/cozy-stack/apps/ + return { + "data": [{ + "id": "4cfbd8be-8968-11e6-9708-ef55b7c20863", + "type": "io.cozy.apps", + "meta": { + "rev": "2-bbfb0fc32dfcdb5333b28934f195b96a" + }, + "attributes": { + "name": "calendar", + "state": "ready", + "slug": "calendar" + }, + "links": { + "self": "/apps/calendar", + "icon": "/apps/calendar/icon", + "related": "https://calendar.alice.example.com/" + } + }] + } + } + } + ) + } + + const cozyClient = { + getStackClient: () => stackClient + } + + const params = { + cozyClient, + onCreateApp: function() {}, + onDeleteApp: function() {}, + } + + beforeAll( async () => { + jest.spyOn(internal.get, 'apps').mockResolvedValue(undefined) + await stack.init(params) + }) + + afterAll(() => { + jest.restoreAllMocks() + }) + + it("should not forward to the old internal client", async () => { + await stack.get.apps() + expect( internal.get.apps ).not.toHaveBeenCalled() + }) + + it("should return the `data` content", async () => { + const data = await stack.get.apps() + expect( data[0].id ).toBe("4cfbd8be-8968-11e6-9708-ef55b7c20863") + }) + + it("should have called cozy-client", async () => { + await stack.get.apps() + expect( cozyClient.getStackClient().fetch ).toHaveBeenCalled() + }) + + it("should not throw", async () => { + expect( () => stack.get.apps() ).not.toThrow() + await expect( stack.get.apps() ).resolves.not.toBe(false) + }) + + }) + +}) diff --git a/test/lib/stack-client/stack-client.getcontext.spec.js b/test/lib/stack-client/stack-client.getcontext.spec.js new file mode 100644 index 000000000..a5c2fdcd1 --- /dev/null +++ b/test/lib/stack-client/stack-client.getcontext.spec.js @@ -0,0 +1,116 @@ +import stack from 'lib/stack-client' + +import internal from 'lib/stack-internal' + +import { + ForbiddenException, + ServerErrorException, + NotFoundException, + MethodNotAllowedException, + UnavailableStackException, + UnauthorizedStackException +} from 'lib/exceptions' + +describe("stack client", () => { + + describe("getContext", () => { + + const context404 = new NotFoundException() + + const context500 = new UnauthorizedStackException() + + const context200 = { + status: 200, + headers: { get: (h) => "application/json" }, + json: () => { + return { + "data": { + "type": "io.cozy.settings", + "id": "io.cozy.settings.context", + "attributes": { + "default_redirection": "drive/#/files", + "help_link": "https://forum.cozy.io/", + "onboarded_redirection": "collect/#/discovery/?intro" + }, + "links": { + "self": "/settings/context" + } + } + } + } + } + + const stackClient = { + token: { token: "mytoken"}, + uri: "https://test.mycozy.cloud", + fetch: jest.fn() + } + + const cozyClient = { + getStackClient: () => stackClient + } + + const params = { + cozyClient, + onCreateApp: function() {}, + onDeleteApp: function() {}, + } + + beforeAll( async () => { + jest.spyOn(internal.get, 'context').mockResolvedValue(undefined) + await stack.init(params) + }) + + beforeEach( () => { + stackClient.fetch.mockClear() + }) + + afterAll(() => { + jest.restoreAllMocks() + }) + + it("should not cache 500 errors", async () => { + stackClient.fetch.mockRejectedValue(context500) + await stack.get.context().catch(() => undefined) + await stack.get.context().catch(() => undefined) + expect( stackClient.fetch ).toHaveBeenCalledTimes(2) + }) + + it("should not forward to the old internal client", async () => { + stackClient.fetch.mockResolvedValue(context200) + await stack.get.context() + expect( internal.get.context ).not.toHaveBeenCalled() + }) + + it("should return the raw json content", async () => { + stackClient.fetch.mockResolvedValue(context200) + const data = await stack.get.context() + expect( data.data.id ).toBe("io.cozy.settings.context") + }) + + it("should be cached", async () => { + stackClient.fetch.mockResolvedValue(context200) + const data1 = await stack.get.context() + // If we did not called mockClear, we + // would have 0 or 1 call to the mock + // depending on preceding tests. + // Here we always should expect 0 + // in the next lines + stackClient.fetch.mockClear() + stackClient.fetch.mockRejectedValue(context404) + const data2 = await stack.get.context() + expect( data1 ).toBe(data2) + expect( stackClient.fetch ).toHaveBeenCalledTimes(0) + }) + + xit("should return the default value for a 404", async () => { + // @TODO deactivated because the previous tests already + // set the cache and the tested function is statefull + stackClient.fetch.mockRejectedValue(context404) + const data = await stack.get.context() + expect( data ).toEqual( { } ) + }) + + }) + +}) diff --git a/test/lib/stack-client/stack-client.getstoragedata.spec.js b/test/lib/stack-client/stack-client.getstoragedata.spec.js new file mode 100644 index 000000000..321cf24a0 --- /dev/null +++ b/test/lib/stack-client/stack-client.getstoragedata.spec.js @@ -0,0 +1,96 @@ +import stack from 'lib/stack-client' + +import internal from 'lib/stack-internal' + +describe("stack client", () => { + + describe("getStorageData", () => { + + const r200 = { + status: 200, + headers: { get: (h) => "application/json" }, + json: () => { + return { + "data": { + "type": "io.cozy.settings", + "id": "io.cozy.settings.disk-usage", + "attributes": { + "is_limited": true, + "quota": "123456789", + "used": "12345678" + } + } + } + } + } + + const r200noquota = { + status: 200, + headers: { get: (h) => "application/json" }, + json: () => { + return { + "data": { + "type": "io.cozy.settings", + "id": "io.cozy.settings.disk-usage", + "attributes": { + "is_limited": false, + "used": "2000111222333" // 2To + } + } + } + } + } + + const stackClient = { + token: { token: "mytoken"}, + uri: "https://test.mycozy.cloud", + fetch: jest.fn() + } + + const cozyClient = { + getStackClient: () => stackClient + } + + const params = { + cozyClient, + onCreateApp: function() {}, + onDeleteApp: function() {}, + } + + beforeAll( async () => { + jest.spyOn(internal.get, 'storageData').mockResolvedValue(undefined) + await stack.init(params) + }) + + beforeEach( () => { + stackClient.fetch.mockClear() + stackClient.fetch.mockResolvedValue(r200) + }) + + afterAll(() => { + jest.restoreAllMocks() + }) + + it("should not forward to the old internal client", async () => { + await stack.get.storageData() + expect( internal.get.storageData ).not.toHaveBeenCalled() + }) + + it("should return a decidated object", async () => { + const data = await stack.get.storageData() + expect( data.usage ).toBe(12345678) + expect( data.quota ).toBe(123456789) + expect( data.isLimited ).toBe(true) + }) + + it("when no quota is provided, one should be given", async () => { + stackClient.fetch.mockResolvedValue(r200noquota) + const data = await stack.get.storageData() + expect( data.usage ).toBe(2000111222333) + expect( data.quota ).toBeGreaterThanOrEqual(data.usage) + expect( data.isLimited ).toBe(false) + }) + + }) + +}) diff --git a/test/lib/stack-client/stack-client.init.spec.js b/test/lib/stack-client/stack-client.init.spec.js new file mode 100644 index 000000000..1e849e5ac --- /dev/null +++ b/test/lib/stack-client/stack-client.init.spec.js @@ -0,0 +1,51 @@ +import stack from 'lib/stack-client' + +import internal from 'lib/stack-internal' +import initializeRealtime from 'lib/realtime' + +jest.mock('lib/realtime'); +initializeRealtime.mockResolvedValue(Promise.resolve()) + +const { + init, +} = stack + + +describe("stack client", () => { + + describe("init", () => { + let cozyClient = { + getStackClient: () => { + return { + token: { token: "mytoken"}, + uri: "https://test.mycozy.cloud" + } + } + } + let params = { + cozyClient, + onCreateApp: function() {}, + onDeleteApp: function() {}, + } + + beforeAll(async () => { + jest.spyOn(internal, 'init').mockResolvedValue(undefined) + await init(params) + }) + + afterAll(() => { + jest.restoreAllMocks() + }) + + it("should not called internal client", () => { + expect( internal.init ).not.toHaveBeenCalled() + }) + + it("should have initialized the realtime", () => { + expect( initializeRealtime ).toHaveBeenCalled() + expect( initializeRealtime.mock.calls[0][0].token ).toBe("mytoken") + expect( initializeRealtime.mock.calls[0][0].url ).toBe("https://test.mycozy.cloud") + }) + }) + +}) diff --git a/test/lib/stack-client/stack-client.logout.spec.js b/test/lib/stack-client/stack-client.logout.spec.js new file mode 100644 index 000000000..f0b055279 --- /dev/null +++ b/test/lib/stack-client/stack-client.logout.spec.js @@ -0,0 +1,50 @@ +import stack from 'lib/stack-client' + +import internal from 'lib/stack-internal' + +describe("stack client", () => { + + describe("logout", () => { + + const stackClient = { + token: { token: "mytoken"}, + uri: "https://test.mycozy.cloud", + fetch: jest.fn().mockResolvedValue({status: 200}) + } + + const cozyClient = { + getStackClient: () => stackClient + } + + const params = { + cozyClient, + onCreateApp: function() {}, + onDeleteApp: function() {}, + } + + beforeAll(async () => { + jest.spyOn(internal, 'logout').mockResolvedValue(undefined) + await stack.init(params) + await stack.logout() + }) + + afterAll(() => { + jest.restoreAllMocks() + }) + + it("should not forward to the old internal client", () => { + expect( internal.logout ).not.toHaveBeenCalled() + }) + + it("should have called cozy-client", () => { + expect( cozyClient.getStackClient().fetch ).toHaveBeenCalled() + }) + + it("should not throw", async () => { + expect( () => stack.logout() ).not.toThrow() + await expect( stack.logout() ).resolves.not.toBe(false) + }) + + }) + +}) diff --git a/test/lib/stack.spec.js b/test/lib/stack.spec.js new file mode 100644 index 000000000..088066de0 --- /dev/null +++ b/test/lib/stack.spec.js @@ -0,0 +1,87 @@ +import internal from 'lib/stack-internal.js' +import client from 'lib/stack-client.js' +import stack from 'lib/stack.js' + + +const cozyURL = "https://test.mycozy.cloud" +const token = "mytoken" +const onCreateApp = function() {} +const onDeleteApp = function() {} + +describe("stack proxy", () => { + + beforeAll(() => { + jest.spyOn(internal, 'init').mockResolvedValue(undefined) + jest.spyOn(internal.get, 'app').mockResolvedValue(undefined) + + jest.spyOn(client, 'init').mockResolvedValue(undefined) + jest.spyOn(client.get, 'app').mockResolvedValue(undefined) + }) + + afterAll(() => { + jest.restoreAllMocks() + }) + + describe("when initialized with an cozyURL + token", () => { + + const params = { cozyURL, token, onCreateApp, onDeleteApp } + + beforeAll(() => { + jest.clearAllMocks() + + stack.init(params) + stack.get.app() + }) + + it("should call the internal stack init", () => { + expect( internal.init ).toHaveBeenCalled() + }) + + it("should not call the cozy-client stack init", () => { + expect( client.init ).not.toHaveBeenCalled() + }) + + it("should forward requests to the internal stack client", () => { + expect( internal.get.app ).toHaveBeenCalled() + }) + + it("should not forward requests to the cozy-client stack client", () => { + expect( client.get.app ).not.toHaveBeenCalled() + }) + + }) + + describe("when initialized with a cozy-client instance", () => { + + const cozyClient = { + getStackClient: () => { + return { + token: { token: "mytoken"}, + uri: "https://test.mycozy.cloud" + } + } + } + const params = { + cozyClient, + onCreateApp: function() {}, + onDeleteApp: function() {}, + } + + beforeAll(() => { + jest.clearAllMocks() + + stack.init(params) + stack.get.app() + }) + + it("should call the client stack init", () => { + expect( client.init ).toHaveBeenCalled() + }) + + it("should forward requests to the cozy-client stack client", () => { + expect( client.get.app ).toHaveBeenCalled() + }) + + }) + +})