UNPKG

@instructure/canvas-rce

Version:

A component wrapping Canvas's usage of Tinymce

820 lines (706 loc) 26.5 kB
/* * Copyright (C) 2018 - present Instructure, Inc. * * This file is part of Canvas. * * Canvas is free software: you can redistribute it and/or modify it under * the terms of the GNU Affero General Public License as published by the Free * Software Foundation, version 3 of the License. * * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more * details. * * You should have received a copy of the GNU Affero General Public License along * with this program. If not, see <http://www.gnu.org/licenses/>. */ import RceApiSource, {headerFor, originFromHost} from '../../src/rcs/api' import fetchMock from 'fetch-mock' import * as fileUrl from '../../src/common/fileUrl' import {ICON_MAKER_ICONS} from '../../src/rce/plugins/instructure_icon_maker/svg/constants' describe('sources/api', () => { const endpoint = 'wikiPages' const props = { host: 'example.host', contextType: 'group', contextId: 123, sortBy: {sort: 'date_added', dir: 'desc'}, searchString: '', } let setProps = {} let apiSource let alertFuncSpy beforeEach(() => { alertFuncSpy = jest.fn() apiSource = new RceApiSource({ jwt: 'theJWT', refreshToken: callback => { callback('freshJWT') }, alertFunc: alertFuncSpy, }) fetchMock.mock('/api/session', '{}') }) afterEach(() => { fetchMock.restore() jest.resetAllMocks() }) describe('initializeCollection', () => { let collection beforeEach(() => { collection = apiSource.initializeCollection(endpoint, props) }) it('creates a collection with no links', () => { expect(collection.links).toEqual([]) }) it('creates a collection with a bookmark derived from props', () => { expect(collection.bookmark).toEqual( `${window.location.protocol}//example.host/api/wikiPages?contextType=group&contextId=123&search_term=panda` ) }) it('bookmark omits host if not in props', () => { const noHostProps = {...props, host: undefined} collection = apiSource.initializeCollection(endpoint, noHostProps) expect(collection.bookmark).toEqual( '/api/wikiPages?contextType=group&contextId=123&search_term=panda' ) }) it('creates a collection that is not initially loading', () => { expect(collection.isLoading).toEqual(false) }) it('creates a collection that initially has more', () => { expect(collection.hasMore).toEqual(true) }) }) describe('initializeImages', () => { it('sets hasMore to true', () => { expect(apiSource.initializeImages(props)[props.contextType].hasMore).toEqual(true) }) it('sets searchString to an empty string', () => { expect(apiSource.initializeImages(props).searchString).toEqual('') }) }) describe('URI construction (baseUri)', () => { it('uses a protocol relative url when no window', () => { const uri = apiSource.baseUri('files', 'example.instructure.com', {}) expect(uri).toEqual('//example.instructure.com/api/files') }) it('uses a path for no-host url construction', () => { const uri = apiSource.baseUri('files') expect(uri).toEqual('/api/files') }) it('gets protocol from window if available', () => { const fakeWindow = {location: {protocol: 'https:'}} const uri = apiSource.baseUri('files', 'example.instructure.com', fakeWindow) expect(uri).toEqual('https://example.instructure.com/api/files') }) it('never applies protocol to path', () => { const fakeWindow = {location: {protocol: 'https:'}} const uri = apiSource.baseUri('files', null, fakeWindow) expect(uri).toEqual('/api/files') }) it("will replace protocol if there's a mismatch from http to https", () => { const fakeWindow = {location: {protocol: 'https:'}} const uri = apiSource.normalizeUriProtocol('http://something.com', fakeWindow) expect(uri).toEqual('https://something.com') }) }) describe('more URI construction (uriFor)', () => { beforeEach(() => { setProps = { host: undefined, contextType: 'course', contextId: '17', sortBy: {sort: 'alphabetical', dir: 'asc'}, searchString: 'hello world', } }) it('gets documents', () => { const uri = apiSource.uriFor('documents', setProps) expect(uri).toEqual( '/api/documents?contextType=course&contextId=17&exclude_content_types=image,video,audio&sort=name&order=asc&search_term=hello%20world' ) }) it('gets images', () => { const uri = apiSource.uriFor('images', setProps) expect(uri).toEqual( '/api/documents?contextType=course&contextId=17&content_types=image&sort=name&order=asc&search_term=hello%20world' ) }) // this endpoint isn't actually used yet, but could be if media_objects all had associated Attachments it('gets media', () => { const uri = apiSource.uriFor('media', setProps) expect(uri).toEqual( '/api/documents?contextType=course&contextId=17&content_types=video,audio&sort=name&order=asc&search_term=hello%20world' ) }) it('gets media_objects', () => { const uri = apiSource.uriFor('media_objects', setProps) expect(uri).toEqual( '/api/media_objects?contextType=course&contextId=17&sort=title&order=asc&search_term=hello%20world' ) }) }) describe('fetchPage', () => { const uri = 'theURI' const fakePageBody = '{"bookmark":"newBookmark","links":[' + '{"href":"link1","title":"Link 1"},' + '{"href":"link2","title":"Link 2"}]}' it('includes jwt in Authorization header', async () => { fetchMock.mock(uri, '{}') await apiSource.fetchPage(uri) expect(fetchMock.lastOptions(uri).headers.Authorization).toEqual('Bearer theJWT') }) it('converts 400+ statuses to errors', async () => { fetchMock.mock(uri, 403) await expect(apiSource.fetchPage(uri)).rejects.toThrow('Forbidden') }) it('parses server response before handing it back', async () => { fetchMock.mock(uri, fakePageBody) const page = await apiSource.fetchPage(uri) expect(page).toEqual({ bookmark: 'newBookmark', links: [ {href: 'link1', title: 'Link 1'}, {href: 'link2', title: 'Link 2'}, ], }) }) it('retries once on 401 with a renewed token', async () => { fetchMock.mock((fetchUrl, opts) => { return uri === fetchUrl && opts.headers.Authorization === 'Bearer theJWT' }, 401) fetchMock.mock((fetchUrl, opts) => { return uri === fetchUrl && opts.headers.Authorization === 'Bearer freshJWT' }, fakePageBody) const page = await apiSource.fetchPage(uri, 'theJWT') expect(page.bookmark).toEqual('newBookmark') expect(apiSource.jwt).toEqual('freshJWT') }) }) describe('fetchFiles', () => { let bookmark, files, wrapUrl beforeEach(() => { bookmark = 'some-bookmark' files = [{url: '/url1'}, {url: '/url2'}] wrapUrl = '/path?preview=1' const body = {bookmark, files} jest.spyOn(apiSource, 'fetchPage').mockReturnValue(Promise.resolve(body)) jest.spyOn(fileUrl, 'downloadToWrap').mockReturnValue(wrapUrl) }) it('proxies the call to fetchPage', async () => { const uri = 'files-uri' const body = await apiSource.fetchFiles(uri) expect(apiSource.fetchPage).toHaveBeenCalledWith(uri) expect(body.bookmark).toEqual(bookmark) }) it('converts file urls from download to preview', async () => { const body = await apiSource.fetchFiles('foo') files.forEach((file, i) => { expect(fileUrl.downloadToWrap).toHaveBeenCalledWith(file.url) expect(body.files[i].href).toEqual(wrapUrl) }) }) }) describe('fetchSubFolders()', () => { let bookmark beforeEach(() => { setProps = {host: 'canvas.rce', folderId: 2} bookmark = undefined jest.spyOn(apiSource, 'apiFetch').mockReturnValue(Promise.resolve({})) }) it('makes a request to the folders api with the given host and ID', () => { apiSource.fetchSubFolders(setProps, bookmark) expect(apiSource.apiFetch).toHaveBeenCalledWith( `${window.location.protocol}//canvas.rce/api/folders/2`, { Authorization: 'Bearer theJWT', } ) }) describe('fetchFilesForFolder()', () => { beforeEach(() => { setProps = {host: 'canvas.rce', filesUrl: 'https://canvas.rce/api/files/2'} bookmark = undefined }) it('makes a request to the files api with given host and folder ID', () => { apiSource.fetchFilesForFolder(setProps, bookmark) expect(apiSource.apiFetch).toHaveBeenCalledWith('https://canvas.rce/api/files/2', { Authorization: 'Bearer theJWT', }) }) describe('with perPage set', () => { beforeEach(() => { setProps.perPage = 50 }) it('includes the "per_page" query param', () => { apiSource.fetchFilesForFolder(setProps, bookmark) expect(apiSource.apiFetch).toHaveBeenCalledWith( 'https://canvas.rce/api/files/2?per_page=50', { Authorization: 'Bearer theJWT', } ) }) }) }) describe('with a provided bookmark', () => { beforeEach(() => (bookmark = 'https://canvas.rce/api/folders/2?page=2')) it('makes a request to the bookmark', () => { apiSource.fetchSubFolders(props, bookmark) expect(apiSource.apiFetch).toHaveBeenCalledWith(bookmark, { Authorization: 'Bearer theJWT', }) }) }) }) describe('fetchBookmarkedData', () => { let fetchFunction, properties, onSuccess, onError beforeEach(() => { fetchFunction = jest .fn() .mockReturnValueOnce(Promise.resolve({bookmark: 'https://canvas.rce/api/thing/1?page=2'})) .mockReturnValueOnce(Promise.resolve({data: 'foo'})) properties = {foo: 'bar'} onSuccess = jest.fn() onError = jest.fn() }) afterEach(() => { jest.resetAllMocks() }) const subject = () => apiSource.fetchBookmarkedData(fetchFunction, properties, onSuccess, onError) it('calls the "fetchFunction", passing "properties"', async () => { await subject() expect(fetchFunction).toHaveBeenCalledWith(properties, undefined) expect(fetchFunction).toHaveBeenCalledWith( properties, 'https://canvas.rce/api/thing/1?page=2' ) expect(fetchFunction).toHaveBeenCalledTimes(2) }) it('calls "onSuccess" for each page', async () => { await subject() expect(onSuccess).toHaveBeenCalledTimes(2) }) describe('when "fetchFunction" throws an exception', () => { beforeEach(() => { jest.resetAllMocks() fetchFunction.mockRejectedValue('error') }) it('calls "onError"', () => { return subject().then(() => { expect(onError).toHaveBeenCalledTimes(1) }) }) }) }) describe('fetchIconMakerFolder', () => { let folders beforeEach(() => { folders = [{id: 24}] const body = {folders} jest.spyOn(apiSource, 'fetchPage').mockReturnValue(Promise.resolve(body)) }) it('calls fetchPage with the proper params', () => { return apiSource .fetchIconMakerFolder({ contextType: 'course', contextId: '22', }) .then(() => { expect(apiSource.fetchPage).toHaveBeenCalledWith( '/api/folders/icon_maker?contextType=course&contextId=22' ) }) }) }) describe('fetchMediaFolder', () => { let files beforeEach(() => { files = [{id: 24}] const body = {files} jest.spyOn(apiSource, 'fetchPage').mockReturnValue(Promise.resolve(body)) }) it('calls fetchPage with the proper params', () => { return apiSource .fetchMediaFolder({ contextType: 'course', contextId: '22', }) .then(() => { expect(apiSource.fetchPage).toHaveBeenCalledWith( '/api/folders/media?contextType=course&contextId=22' ) }) }) }) describe('preflightUpload', () => { const uri = '/api/upload' const fileProps = {} const apiProps = {} afterEach(() => { fetchMock.restore() }) it('includes "onDuplicate"', () => { fetchMock.mock(uri, '{}') return apiSource.preflightUpload(fileProps, {onDuplicate: 'overwrite'}, apiProps).then(() => { const body = JSON.parse(fetchMock.lastOptions(uri).body) expect(body.onDuplicate).toEqual('overwrite') }) }) it('includes "category"', () => { fetchMock.mock(uri, '{}') return apiSource .preflightUpload(fileProps, {category: ICON_MAKER_ICONS}, apiProps) .then(() => { const body = JSON.parse(fetchMock.lastOptions(uri).body) expect(body.category).toEqual(ICON_MAKER_ICONS) }) }) it('includes jwt in Authorization header', () => { fetchMock.mock(uri, '{}') return apiSource.preflightUpload(fileProps, apiProps).then(() => { expect(fetchMock.lastOptions(uri).headers.Authorization).toEqual('Bearer theJWT') }) }) it('retries once with fresh token on 401', () => { fetchMock.mock((fetchUrl, opts) => { return uri === fetchUrl && opts.headers.Authorization === 'Bearer theJWT' }, 401) fetchMock.mock((fetchUrl, opts) => { return uri === fetchUrl && opts.headers.Authorization === 'Bearer freshJWT' }, '{"upload": "done"}') return apiSource.preflightUpload(fileProps, apiProps).then(response => { expect(response.upload).toEqual('done') }) }) it('notifies a provided callback when a new token is fetched', () => { fetchMock.mock((fetchUrl, opts) => { return uri === fetchUrl && opts.headers.Authorization === 'Bearer theJWT' }, 401) fetchMock.mock((fetchUrl, opts) => { return uri === fetchUrl && opts.headers.Authorization === 'Bearer freshJWT' }, '{"upload": "done"}') return apiSource.preflightUpload(fileProps, apiProps).then(() => { expect(apiSource.jwt).toEqual('freshJWT') }) }) it('calls alertFunc when an error occurs', () => { fetchMock.mock(uri, 500) return apiSource .preflightUpload(fileProps, apiProps) .then(() => { expect(alertFuncSpy).toHaveBeenCalledWith({ text: 'Something went wrong uploading, check your connection and try again.', variant: 'error', }) }) .catch(() => { // This will re-throw so we just catch it here. }) }) it('throws an exception when an error occurs', () => { fetchMock.mock(uri, 500) return apiSource.preflightUpload(fileProps, apiProps).catch(e => { expect(e).not.toBeNull() }) }) describe('when the file storage quota is exceeded', () => { beforeEach(() => { const error = new Error('file size exceeds quota') error.response = {json: async () => ({message: 'file size exceeds quota'})} fetchMock.mock(uri, {throws: error}, {overwriteRoutes: true}) }) it('gives a "quota" error if quota is full', async () => { try { await apiSource.preflightUpload(fileProps, apiProps) expect(alertFuncSpy).toHaveBeenCalledWith({ text: 'File storage quota exceeded', variant: 'error', }) } catch (e) { return e } // This will re-throw so we just catch it here/ }) }) }) describe('uploadFRD', () => { let fileDomObject, uploadUrl, preflightProps, file, wrapUrl beforeEach(() => { fileDomObject = new window.Blob() uploadUrl = 'upload-url' preflightProps = { upload_params: {}, upload_url: uploadUrl, } file = {url: 'file-url'} fetchMock.mock(uploadUrl, file) }) afterEach(() => { fetchMock.restore() }) it('calls alertFunc if there is a problem', () => { fetchMock.once(uploadUrl, 500, {overwriteRoutes: true}) return apiSource .uploadFRD(fileDomObject, preflightProps) .then(() => { expect(alertFuncSpy).toHaveBeenCalledWith({ text: 'Something went wrong uploading, check your connection and try again.', variant: 'error', }) }) .catch(() => {}) }) describe('files', () => { beforeEach(() => { wrapUrl = '/groups/123/path?wrap=1' jest.spyOn(fileUrl, 'downloadToWrap').mockReturnValue(wrapUrl) jest.spyOn(fileUrl, 'fixupFileUrl').mockReturnValue(wrapUrl) jest.spyOn(apiSource, 'getFile').mockReturnValue(Promise.resolve(file)) }) afterEach(() => { jest.restoreAllMocks() }) it('includes credentials in non-S3 upload', () => { preflightProps.upload_params.success_url = undefined return apiSource.uploadFRD(fileDomObject, preflightProps).then(() => { expect(fetchMock.lastOptions(uploadUrl).credentials).toEqual('include') }) }) it('does not include credentials in S3 upload', () => { preflightProps.upload_params['x-amz-signature'] = 'success-url' preflightProps.upload_params.success_url = 'success-url' const s3File = {url: 's3-file-url'} fetchMock.mock(preflightProps.upload_params.success_url, s3File) return apiSource.uploadFRD(fileDomObject, preflightProps).then(() => { expect(fetchMock.lastOptions(uploadUrl).credentials).toBeUndefined() }) }) it('does not include credentials in a local cross-origin upload', () => { preflightProps.upload_params.success_url = undefined const crossOriginUploadUrl = 'cross-origin.site/files_api' preflightProps.upload_url = crossOriginUploadUrl fetchMock.mock(crossOriginUploadUrl, file) return apiSource.uploadFRD(fileDomObject, preflightProps).then(() => { expect(fetchMock.lastOptions(crossOriginUploadUrl).credentials).toBeUndefined() }) }) it('handles s3 post-flight', async () => { preflightProps.upload_params.success_url = 'success-url' const s3File = {url: 's3-file-url'} fetchMock.mock(preflightProps.upload_params.success_url, s3File) const result = await apiSource.uploadFRD(fileDomObject, preflightProps) expect(result).toEqual(s3File) }) it('handles inst-fs post-flight', () => { preflightProps.upload_url = 'instfs-upload-url' const fileId = '123' const response = { location: `http://canvas/api/v1/files/${fileId}?foo=bar`, uuid: 'xyzzy', } fetchMock.mock(preflightProps.upload_url, response) return apiSource.uploadFRD(fileDomObject, preflightProps).then(resp => { expect(apiSource.getFile).toHaveBeenCalledWith(fileId) expect(resp.uuid).toEqual('xyzzy') expect(resp.url).toEqual('file-url') }) }) it('handles inst-fs post-flight with global file id', () => { preflightProps.upload_url = 'instfs-upload-url' const fileId = '1023~789' const response = { location: `http://canvas/api/v1/files/${fileId}?foo=bar`, uuid: 'xyzzy', } fetchMock.mock(preflightProps.upload_url, response) return apiSource.uploadFRD(fileDomObject, preflightProps).then(resp => { expect(apiSource.getFile).toHaveBeenCalledWith(fileId) expect(resp.uuid).toEqual('xyzzy') expect(resp.url).toEqual('file-url') }) }) }) }) describe('api mapping', () => { const body = { bookmark: 'mo.images', files: [{href: '/some/where', uuid: 'xyzzy'}], } props.images = { group: { isLoading: false, hasMore: true, bookmark: null, files: [], }, } props.searchString = 'panda' it('can fetch folders', async () => { fetchMock.mock(/\/folders\?/, {body}) const page = await apiSource.fetchRootFolder(props) expect(page).toEqual(body) }) it('requests images from API', async () => { fetchMock.mock(/\/documents\?.*content_types=image/, {body}) const page = await apiSource.fetchImages(props) expect(page).toEqual({ bookmark: 'mo.images', files: [{href: '/some/where?wrap=1', uuid: 'xyzzy'}], searchString: 'panda', }) }) it('requests subsequent page of images from API', async () => { props.images.group.bookmark = 'mo.images' fetchMock.mock(/\/documents\?.*content_types=image/, 'should not get here') fetchMock.mock(/mo.images/, {body}) const page = await apiSource.fetchImages(props) expect(page).toEqual({ bookmark: 'mo.images', files: [{href: '/some/where?wrap=1', uuid: 'xyzzy'}], searchString: 'panda', }) }) }) describe('getSession', () => { const uri = '/api/session' // already mocked it('includes jwt in Authorization header', () => { return apiSource.getSession().then(() => { expect(fetchMock.lastOptions(uri).headers.Authorization).toEqual('Bearer theJWT') }) }) }) describe('setUsageRights', () => { const uri = '/api/usage_rights' const fileId = 47 const usageRights = {usageRight: 'foo'} beforeEach(() => { fetchMock.mock(uri, '{}') }) it('includes jwt in Authorization header', () => { return apiSource.setUsageRights(fileId, usageRights).then(() => { expect(fetchMock.lastOptions(uri).headers.Authorization).toEqual('Bearer theJWT') }) }) it('posts file id and usage rights to the api', () => { return apiSource.setUsageRights(fileId, usageRights).then(() => { const postBody = JSON.parse(fetchMock.lastOptions(uri).body) expect(postBody).toEqual({ fileId, usageRight: usageRights.usageRight, }) }) }) }) describe('getFile', () => { const id = 47 const uri = `/api/file/${id}` let url = '/file/url' setProps = {} it('includes jwt in Authorization header', () => { fetchMock.mock(uri, {url}) return apiSource.getFile(id, setProps).then(() => { expect(fetchMock.lastOptions(uri).headers.Authorization).toEqual('Bearer theJWT') }) }) it('retries once with fresh token on 401', () => { fetchMock.mock((fetchUrl, opts) => { return uri === fetchUrl && opts.headers.Authorization === 'Bearer theJWT' }, 401) fetchMock.mock( (fetchUrl, opts) => { return uri === fetchUrl && opts.headers.Authorization === 'Bearer freshJWT' }, {upload: 'done', url} ) return apiSource.getFile(id, setProps).then(response => { expect(response.upload).toEqual('done') }) }) it('notifies a provided callback when a new token is fetched', () => { fetchMock.mock((fetchUrl, opts) => { return uri === fetchUrl && opts.headers.Authorization === 'Bearer theJWT' }, 401) fetchMock.mock( (fetchUrl, opts) => { return uri === fetchUrl && opts.headers.Authorization === 'Bearer freshJWT' }, {upload: 'done', url} ) return apiSource.getFile(id, setProps).then(() => { expect(apiSource.jwt).toEqual('freshJWT') }) }) it('transforms file url with downloadToWrap', () => { url = '/file/url?download_frd=1' const wrapUrl = '/file/url?wrap=1' fetchMock.mock('*', {url}) jest.spyOn(fileUrl, 'downloadToWrap').mockReturnValue(wrapUrl) return apiSource.getFile(id).then(file => { expect(fileUrl.downloadToWrap).toHaveBeenCalledWith(url) expect(file.href).toEqual(wrapUrl) fetchMock.restore() }) }) it('defaults display_name to name', () => { url = '/file/url?download_frd=1' const name = 'filename' fetchMock.mock('*', {url, name}) jest.spyOn(fileUrl, 'downloadToWrap') return apiSource.getFile(id).then(file => { expect(file.display_name).toEqual(name) fetchMock.restore() }) }) }) describe('media object apis', () => { describe('updateMediaObject', () => { it('PUTs to the media_object endpoint', async () => { const uri = `/api/media_objects/m-id?user_entered_title=${encodeURIComponent('new title')}` fetchMock.put(uri, '{"media_id": "m-id", "title": "new title"}') const response = await apiSource.updateMediaObject( {}, {media_object_id: 'm-id', title: 'new title'} ) expect(fetchMock.lastOptions(uri).headers.Authorization).toEqual('Bearer theJWT') expect(response).toEqual({media_id: 'm-id', title: 'new title'}) }) }) }) describe('headerFor', () => { it('returns an authorization header', () => { expect(headerFor('the_jwt')).toEqual({ Authorization: 'Bearer the_jwt', }) }) }) describe('originFromHost', () => { // this logic was factored out from baseUri, so the logic is tested // there too. it('uses the incoming http protocol if present', () => { expect(originFromHost('http://host:port')).toEqual('http://host:port') }) it('uses the incoming https protocol if present', () => { expect(originFromHost('https://host:port')).toEqual('https://host:port') }) it('uses the provided protocol if present', () => { const win = { location: { protocol: 'https:', }, } expect(originFromHost('http://host:port', win)).toEqual('http://host:port') }) it('uses the window protocol if not present', () => { const win = { location: { protocol: 'https:', }, } expect(originFromHost('host:port', win)).toEqual('https://host:port') }) }) })