diff --git a/jest.config.js b/jest.config.js index 8467e200..a3770199 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,6 +4,7 @@ module.exports = { '\\.js$': '/custom-preprocessor.js', }, moduleNameMapper: { + 'netlify-cms-lib-auth': '/packages/netlify-cms-lib-auth/src/index.js', 'netlify-cms-lib-util': '/packages/netlify-cms-lib-util/src/index.js', 'netlify-cms-ui-default': '/packages/netlify-cms-ui-default/src/index.js', }, diff --git a/package.json b/package.json index e1633409..abed07ce 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,8 @@ "jest-dom": "^3.1.3", "jest-emotion": "^10.0.9", "ncp": "^2.0.0", + "nock": "^10.0.4", + "node-fetch": "^2.3.0", "npm-run-all": "^4.1.5", "prettier": "1.17.1", "react-test-renderer": "^16.8.4", diff --git a/packages/netlify-cms-backend-gitlab/src/__tests__/gitlab.spec.js b/packages/netlify-cms-backend-gitlab/src/__tests__/gitlab.spec.js new file mode 100644 index 00000000..e45df7da --- /dev/null +++ b/packages/netlify-cms-backend-gitlab/src/__tests__/gitlab.spec.js @@ -0,0 +1,488 @@ +jest.mock('netlify-cms-core/src/backend'); +import { fromJS } from 'immutable'; +import { partial } from 'lodash'; +import { oneLine, stripIndent } from 'common-tags'; +import nock from 'nock'; +import { Cursor } from 'netlify-cms-lib-util'; +import Gitlab from '../implementation'; +import AuthenticationPage from '../AuthenticationPage'; + +const { Backend, LocalStorageAuthStore } = jest.requireActual('netlify-cms-core/src/backend'); + +const generateEntries = (path, length) => { + const entries = Array.from({ length }, (val, idx) => { + const count = idx + 1; + const id = `00${count}`.slice(-3); + const fileName = `test${id}.md`; + return { id, fileName, filePath: `${path}/${fileName}` }; + }); + + return { + tree: entries.map(({ id, fileName, filePath }) => ({ + id: `d8345753a1d935fa47a26317a503e73e1192d${id}`, + name: fileName, + type: 'blob', + path: filePath, + mode: '100644', + })), + files: entries.reduce( + (acc, { id, filePath }) => ({ + ...acc, + [filePath]: stripIndent` + --- + title: test ${id} + --- + # test ${id} + `, + }), + {}, + ), + }; +}; + +const manyEntries = generateEntries('many-entries', 500); + +const mockRepo = { + tree: { + '/': [ + { + id: '5d0620ebdbc92068a3e866866e928cc373f18429', + name: 'content', + type: 'tree', + path: 'content', + mode: '040000', + }, + ], + content: [ + { + id: 'b1a200e48be54fde12b636f9563d659d44c206a5', + name: 'test1.md', + type: 'blob', + path: 'content/test1.md', + mode: '100644', + }, + { + id: 'd8345753a1d935fa47a26317a503e73e1192d623', + name: 'test2.md', + type: 'blob', + path: 'content/test2.md', + mode: '100644', + }, + ], + 'many-entries': manyEntries.tree, + }, + files: { + 'content/test1.md': stripIndent` + --- + title: test + --- + # test + `, + 'content/test2.md': stripIndent` + --- + title: test2 + --- + # test 2 + `, + ...manyEntries.files, + }, +}; + +const resp = { + user: { + success: { + id: 1, + }, + }, + project: { + success: { + permissions: { + project_access: { + access_level: 30, + }, + }, + }, + readOnly: { + permissions: { + project_access: { + access_level: 10, + }, + }, + }, + }, +}; + +describe('gitlab backend', () => { + let authStore; + let backend; + const repo = 'foo/bar'; + const defaultConfig = { + backend: { + name: 'gitlab', + repo, + }, + }; + const collectionContentConfig = { + name: 'foo', + folder: 'content', + fields: [{ name: 'title' }], + // TODO: folder_based_collection is an internal string, we should not + // be depending on it here + type: 'folder_based_collection', + }; + const collectionManyEntriesConfig = { + name: 'foo', + folder: 'many-entries', + fields: [{ name: 'title' }], + // TODO: folder_based_collection is an internal string, we should not + // be depending on it here + type: 'folder_based_collection', + }; + const collectionFilesConfig = { + name: 'foo', + files: [ + { + label: 'foo', + name: 'foo', + file: 'content/test1.md', + fields: [{ name: 'title' }], + }, + { + label: 'bar', + name: 'bar', + file: 'content/test2.md', + fields: [{ name: 'title' }], + }, + ], + type: 'file_based_collection', + }; + const mockCredentials = { token: 'MOCK_TOKEN' }; + const expectedRepo = encodeURIComponent(repo); + const expectedRepoUrl = `/projects/${expectedRepo}`; + + function resolveBackend(config = {}) { + authStore = new LocalStorageAuthStore(); + return new Backend( + { + init: (...args) => new Gitlab(...args), + }, + { + backendName: 'gitlab', + config: fromJS(config), + authStore, + }, + ); + } + + function mockApi(backend) { + return nock(backend.implementation.api_root); + } + + function interceptAuth(backend, { userResponse, projectResponse } = {}) { + const api = mockApi(backend); + api + .get('/user') + .query(true) + .reply(200, userResponse || resp.user.success); + + api + .get(expectedRepoUrl) + .query(true) + .reply(200, projectResponse || resp.project.success); + } + + function parseQuery(uri) { + const query = uri.split('?')[1]; + if (!query) { + return {}; + } + return query.split('&').reduce((acc, q) => { + const [key, value] = q.split('='); + acc[key] = value; + return acc; + }, {}); + } + + function createHeaders(backend, { basePath, path, page, perPage, pageCount, totalCount }) { + const pageNum = parseInt(page, 10); + const pageCountNum = parseInt(pageCount, 10); + const url = `${backend.implementation.api_root}${basePath}`; + const link = linkPage => + `<${url}?id=${expectedRepo}&page=${linkPage}&path=${path}&per_page=${perPage}&recursive=false>`; + + const linkHeader = oneLine` + ${link(1)}; rel="first", + ${link(pageCount)}; rel="last", + ${pageNum === 1 ? '' : `${link(pageNum - 1)}; rel="prev",`} + ${pageNum === pageCountNum ? '' : `${link(pageNum + 1)}; rel="next",`} + `.slice(0, -1); + + return { + 'X-Page': page, + 'X-Total-Pages': pageCount, + 'X-Per-Page': perPage, + 'X-Total': totalCount, + Link: linkHeader, + }; + } + + function interceptCollection( + backend, + collection, + { verb = 'get', repeat = 1, page: expectedPage } = {}, + ) { + const api = mockApi(backend); + const url = `${expectedRepoUrl}/repository/tree`; + const { folder } = collection; + const tree = mockRepo.tree[folder]; + api[verb](url) + .query(({ path, page }) => { + if (path !== folder) { + return false; + } + if (expectedPage && page && parseInt(page, 10) !== parseInt(expectedPage, 10)) { + return false; + } + return true; + }) + .times(repeat) + .reply(uri => { + const { page = 1, per_page = 20 } = parseQuery(uri); + const pageCount = tree.length <= per_page ? 1 : Math.round(tree.length / per_page); + const pageLastIndex = page * per_page; + const pageFirstIndex = pageLastIndex - per_page; + const resp = tree.slice(pageFirstIndex, pageLastIndex); + return [ + 200, + verb === 'head' ? null : resp, + createHeaders(backend, { + basePath: url, + path: folder, + page, + perPage: per_page, + pageCount, + totalCount: tree.length, + }), + ]; + }); + } + + function interceptFiles(backend, path) { + const api = mockApi(backend); + const url = `${expectedRepoUrl}/repository/files/${encodeURIComponent(path)}/raw`; + api + .get(url) + .query(true) + .reply(200, mockRepo.files[path]); + } + + function sharedSetup() { + beforeEach(async () => { + backend = resolveBackend(defaultConfig); + interceptAuth(backend); + await backend.authenticate(mockCredentials); + interceptCollection(backend, collectionManyEntriesConfig, { verb: 'head' }); + interceptCollection(backend, collectionContentConfig, { verb: 'head' }); + }); + } + + it('throws if configuration requires editorial workflow', () => { + const resolveBackendWithWorkflow = partial(resolveBackend, { + ...defaultConfig, + publish_mode: 'editorial_workflow', + }); + expect(resolveBackendWithWorkflow).toThrowErrorMatchingInlineSnapshot( + `"The GitLab backend does not support the Editorial Workflow."`, + ); + }); + + it('throws if configuration does not include repo', () => { + expect(resolveBackend).toThrowErrorMatchingInlineSnapshot( + `"The GitLab backend needs a \\"repo\\" in the backend configuration."`, + ); + }); + + describe('authComponent', () => { + it('returns authentication page component', () => { + backend = resolveBackend(defaultConfig); + expect(backend.authComponent()).toEqual(AuthenticationPage); + }); + }); + + describe('authenticate', () => { + it('throws if user does not have access to project', async () => { + backend = resolveBackend(defaultConfig); + interceptAuth(backend, { projectResponse: resp.project.readOnly }); + await expect( + backend.authenticate(mockCredentials), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Your GitLab user account does not have access to this repo."`, + ); + }); + + it('stores and returns user object on success', async () => { + const backendName = defaultConfig.backend.name; + backend = resolveBackend(defaultConfig); + interceptAuth(backend); + const user = await backend.authenticate(mockCredentials); + expect(authStore.retrieve()).toEqual(user); + expect(user).toEqual({ ...resp.user.success, ...mockCredentials, backendName }); + }); + }); + + describe('currentUser', () => { + it('returns null if no user', async () => { + backend = resolveBackend(defaultConfig); + const user = await backend.currentUser(); + expect(user).toEqual(null); + }); + + it('returns the stored user if exists', async () => { + const backendName = defaultConfig.backend.name; + backend = resolveBackend(defaultConfig); + interceptAuth(backend); + await backend.authenticate(mockCredentials); + const user = await backend.currentUser(); + expect(user).toEqual({ ...resp.user.success, ...mockCredentials, backendName }); + }); + }); + + describe('getToken', () => { + it('returns the token for the current user', async () => { + backend = resolveBackend(defaultConfig); + interceptAuth(backend); + await backend.authenticate(mockCredentials); + const token = await backend.getToken(); + expect(token).toEqual(mockCredentials.token); + }); + }); + + describe('logout', () => { + it('sets token to null', async () => { + backend = resolveBackend(defaultConfig); + interceptAuth(backend); + await backend.authenticate(mockCredentials); + await backend.logout(); + const token = await backend.getToken(); + expect(token).toEqual(null); + }); + }); + + describe('getEntry', () => { + sharedSetup(); + + it('returns an entry from folder collection', async () => { + const entryTree = mockRepo.tree[collectionContentConfig.folder][0]; + const slug = entryTree.path + .split('/') + .pop() + .replace('.md', ''); + + interceptFiles(backend, entryTree.path); + interceptCollection(backend, collectionContentConfig); + const entry = await backend.getEntry(fromJS(collectionContentConfig), slug); + + expect(entry).toEqual(expect.objectContaining({ path: entryTree.path })); + }); + }); + + describe('listEntries', () => { + sharedSetup(); + + it('returns entries from folder collection', async () => { + const tree = mockRepo.tree[collectionContentConfig.folder]; + tree.forEach(file => interceptFiles(backend, file.path)); + + interceptCollection(backend, collectionContentConfig); + const entries = await backend.listEntries(fromJS(collectionContentConfig)); + + expect(entries).toEqual({ + cursor: expect.any(Cursor), + entries: expect.arrayContaining( + tree.map(file => expect.objectContaining({ path: file.path })), + ), + }); + expect(entries.entries).toHaveLength(2); + }); + + it('returns all entries from folder collection', async () => { + const tree = mockRepo.tree[collectionManyEntriesConfig.folder]; + tree.forEach(file => interceptFiles(backend, file.path)); + + interceptCollection(backend, collectionManyEntriesConfig, { repeat: 5 }); + const entries = await backend.listAllEntries(fromJS(collectionManyEntriesConfig)); + + expect(entries).toEqual( + expect.arrayContaining(tree.map(file => expect.objectContaining({ path: file.path }))), + ); + expect(entries).toHaveLength(500); + }, 7000); + + it('returns entries from file collection', async () => { + const { files } = collectionFilesConfig; + files.forEach(file => interceptFiles(backend, file.file)); + const entries = await backend.listEntries(fromJS(collectionFilesConfig)); + + expect(entries).toEqual({ + cursor: expect.any(Cursor), + entries: expect.arrayContaining( + files.map(file => expect.objectContaining({ path: file.file })), + ), + }); + expect(entries.entries).toHaveLength(2); + }); + + it('returns last page from paginated folder collection tree', async () => { + const tree = mockRepo.tree[collectionManyEntriesConfig.folder]; + const pageTree = tree.slice(-20); + pageTree.forEach(file => interceptFiles(backend, file.path)); + interceptCollection(backend, collectionManyEntriesConfig, { page: 25 }); + const entries = await backend.listEntries(fromJS(collectionManyEntriesConfig)); + + expect(entries.entries).toEqual( + expect.arrayContaining(pageTree.map(file => expect.objectContaining({ path: file.path }))), + ); + expect(entries.entries).toHaveLength(20); + }); + }); + + describe('traverseCursor', () => { + sharedSetup(); + + it('returns complete last page of paginated tree', async () => { + const tree = mockRepo.tree[collectionManyEntriesConfig.folder]; + tree.slice(-20).forEach(file => interceptFiles(backend, file.path)); + interceptCollection(backend, collectionManyEntriesConfig, { page: 25 }); + const entries = await backend.listEntries(fromJS(collectionManyEntriesConfig)); + + const nextPageTree = tree.slice(-40, -20); + nextPageTree.forEach(file => interceptFiles(backend, file.path)); + interceptCollection(backend, collectionManyEntriesConfig, { page: 24 }); + const nextPage = await backend.traverseCursor(entries.cursor, 'next'); + + expect(nextPage.entries).toEqual( + expect.arrayContaining( + nextPageTree.map(file => expect.objectContaining({ path: file.path })), + ), + ); + expect(nextPage.entries).toHaveLength(20); + + const prevPageTree = tree.slice(-20); + interceptCollection(backend, collectionManyEntriesConfig, { page: 25 }); + const prevPage = await backend.traverseCursor(nextPage.cursor, 'prev'); + expect(prevPage.entries).toEqual( + expect.arrayContaining( + prevPageTree.map(file => expect.objectContaining({ path: file.path })), + ), + ); + expect(prevPage.entries).toHaveLength(20); + }); + }); + + afterEach(() => { + nock.cleanAll(); + authStore.logout(); + backend = null; + expect(authStore.retrieve()).toEqual(null); + }); +}); diff --git a/packages/netlify-cms-core/src/backend.js b/packages/netlify-cms-core/src/backend.js index c3671b38..78b54fea 100644 --- a/packages/netlify-cms-core/src/backend.js +++ b/packages/netlify-cms-core/src/backend.js @@ -32,7 +32,7 @@ import { dateParsers, } from 'Lib/stringTemplate'; -class LocalStorageAuthStore { +export class LocalStorageAuthStore { storageKey = 'netlify-cms-user'; retrieve() { @@ -200,7 +200,7 @@ function createPreviewUrl(baseUrl, collection, slug, slugConfig, entry) { return `${basePath}/${previewPath}`; } -class Backend { +export class Backend { constructor(implementation, { backendName, authStore = null, config } = {}) { // We can't reliably run this on exit, so we do cleanup on load. this.deleteAnonymousBackup(); @@ -224,10 +224,10 @@ class Backend { const stored = this.authStore && this.authStore.retrieve(); if (stored && stored.backendName === this.backendName) { return Promise.resolve(this.implementation.restoreUser(stored)).then(user => { - const newUser = { ...user, backendName: this.backendName }; + this.user = { ...user, backendName: this.backendName }; // return confirmed/rehydrated user object instead of stored - this.authStore.store(newUser); - return newUser; + this.authStore.store(this.user); + return this.user; }); } return Promise.resolve(null); @@ -236,9 +236,9 @@ class Backend { updateUserCredentials = updatedCredentials => { const storedUser = this.authStore && this.authStore.retrieve(); if (storedUser && storedUser.backendName === this.backendName) { - const newUser = { ...storedUser, ...updatedCredentials }; - this.authStore.store(newUser); - return newUser; + this.user = { ...storedUser, ...updatedCredentials }; + this.authStore.store(this.user); + return this.user; } }; @@ -248,16 +248,17 @@ class Backend { authenticate(credentials) { return this.implementation.authenticate(credentials).then(user => { - const newUser = { ...user, backendName: this.backendName }; + this.user = { ...user, backendName: this.backendName }; if (this.authStore) { - this.authStore.store(newUser); + this.authStore.store(this.user); } - return newUser; + return this.user; }); } logout() { return Promise.resolve(this.implementation.logout()).then(() => { + this.user = null; if (this.authStore) { this.authStore.logout(); } diff --git a/setupTestFramework.js b/setupTestFramework.js index 8294a018..1d327a9e 100644 --- a/setupTestFramework.js +++ b/setupTestFramework.js @@ -1,5 +1,7 @@ /* eslint-disable emotion/no-vanilla */ +import fetch from 'node-fetch'; import * as emotion from 'emotion'; import { createSerializer } from 'jest-emotion'; +window.fetch = fetch; expect.addSnapshotSerializer(createSerializer(emotion)); diff --git a/yarn.lock b/yarn.lock index fb0c26e9..9b8e0f66 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2520,6 +2520,11 @@ assert@^1.1.1: object-assign "^4.1.1" util "0.10.3" +assertion-error@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" + integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== + assign-symbols@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" @@ -3288,6 +3293,18 @@ ccount@^1.0.0: resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.0.4.tgz#9cf2de494ca84060a2a8d2854edd6dfb0445f386" integrity sha512-fpZ81yYfzentuieinmGnphk0pLkOTMm6MZdVqwd77ROvhko6iujLNGrHH5E7utq3ygWklwfmwuG+A7P+NpqT6w== +chai@^4.1.2: + version "4.2.0" + resolved "https://registry.yarnpkg.com/chai/-/chai-4.2.0.tgz#760aa72cf20e3795e84b12877ce0e83737aa29e5" + integrity sha512-XQU3bhBukrOsQCuwZndwGcCVQHyZi53fQ6Ys1Fym7E4olpIqqZZhhoFJoaKVvV17lWQoXYwgWN2nF5crA8J2jw== + dependencies: + assertion-error "^1.1.0" + check-error "^1.0.2" + deep-eql "^3.0.1" + get-func-name "^2.0.0" + pathval "^1.1.0" + type-detect "^4.0.5" + chain-function@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/chain-function/-/chain-function-1.0.1.tgz#c63045e5b4b663fb86f1c6e186adaf1de402a1cc" @@ -3338,6 +3355,11 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== +check-error@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82" + integrity sha1-V00xLt2Iu13YkS6Sht1sCu1KrII= + check-more-types@2.24.0: version "2.24.0" resolved "https://registry.yarnpkg.com/check-more-types/-/check-more-types-2.24.0.tgz#1420ffb10fd444dcfc79b43891bbfffd32a84600" @@ -4218,7 +4240,14 @@ dedent@^0.7.0: resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" integrity sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw= -deep-equal@^1.0.1: +deep-eql@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df" + integrity sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw== + dependencies: + type-detect "^4.0.0" + +deep-equal@^1.0.0, deep-equal@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" integrity sha1-9dJgKStmDghO/0zbyfCK0yR0SLU= @@ -5644,6 +5673,11 @@ get-document@1: resolved "https://registry.yarnpkg.com/get-document/-/get-document-1.0.0.tgz#4821bce66f1c24cb0331602be6cb6b12c4f01c4b" integrity sha1-SCG85m8cJMsDMWAr5strEsTwHEs= +get-func-name@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41" + integrity sha1-6td0q+5y4gQJQzoGY2YCPdaIekE= + get-pkg-repo@^1.0.0: version "1.4.0" resolved "https://registry.yarnpkg.com/get-pkg-repo/-/get-pkg-repo-1.4.0.tgz#c73b489c06d80cc5536c2c853f9e05232056972d" @@ -8528,6 +8562,21 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +nock@^10.0.4: + version "10.0.6" + resolved "https://registry.yarnpkg.com/nock/-/nock-10.0.6.tgz#e6d90ee7a68b8cfc2ab7f6127e7d99aa7d13d111" + integrity sha512-b47OWj1qf/LqSQYnmokNWM8D88KvUl2y7jT0567NB3ZBAZFz2bWp2PC81Xn7u8F2/vJxzkzNZybnemeFa7AZ2w== + dependencies: + chai "^4.1.2" + debug "^4.1.0" + deep-equal "^1.0.0" + json-stringify-safe "^5.0.1" + lodash "^4.17.5" + mkdirp "^0.5.0" + propagate "^1.0.0" + qs "^6.5.1" + semver "^5.5.0" + node-addon-api@^1.6.0: version "1.6.3" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-1.6.3.tgz#3998d4593e2dca2ea82114670a4eb003386a9fe1" @@ -9358,6 +9407,11 @@ path-type@^3.0.0: dependencies: pify "^3.0.0" +pathval@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0" + integrity sha1-uULm1L3mUwBe9rcTYd74cn0GReA= + pause-stream@0.0.11: version "0.0.11" resolved "https://registry.yarnpkg.com/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445" @@ -9712,6 +9766,11 @@ prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6, object-assign "^4.1.1" react-is "^16.8.1" +propagate@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/propagate/-/propagate-1.0.0.tgz#00c2daeedda20e87e3782b344adba1cddd6ad709" + integrity sha1-AMLa7t2iDofjeCs0Stuhzd1q1wk= + property-information@^3.0.0, property-information@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/property-information/-/property-information-3.2.0.tgz#fd1483c8fbac61808f5fe359e7693a1f48a58331" @@ -9826,7 +9885,7 @@ q@^1.1.2, q@^1.5.1: resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= -qs@6.7.0: +qs@6.7.0, qs@^6.5.1: version "6.7.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== @@ -12260,6 +12319,11 @@ type-check@~0.3.2: dependencies: prelude-ls "~1.1.2" +type-detect@^4.0.0, type-detect@^4.0.5: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + type-fest@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.4.1.tgz#8bdf77743385d8a4f13ba95f610f5ccd68c728f8"