diff --git a/docs/openapi.yml b/docs/openapi.yml index 22fa1e384..88cf89971 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -1139,6 +1139,10 @@ paths: $ref: '#/components/schemas/TemplateRepo' 400: description: Bad request + content: + text/plain: + schema: + type: string 423: description: The templates object is currently locked to prevent incorrect data being accessed and returned. Retry later delete: @@ -1989,6 +1993,13 @@ components: type: boolean protected: type: boolean + gitCredentials: + type: object + properties: + username: + type: string + password: + type: string TemplateRepo: type: object required: diff --git a/src/pfe/portal/modules/ExtensionList.js b/src/pfe/portal/modules/ExtensionList.js index 08a077fbb..635b65ed8 100644 --- a/src/pfe/portal/modules/ExtensionList.js +++ b/src/pfe/portal/modules/ExtensionList.js @@ -190,7 +190,10 @@ async function addExtensionsToTemplates(extensions, templates) { try { if (extension.templates) { log.trace(`Adding Extension ${extension.name}'s repository into the templates`); - await templates.addRepository(extension.templates, extension.description); + await templates.addRepository({ + url: extension.templates, + description: extension.description, + }); } else if (extension.templatesProvider) { log.trace(`Adding Extension ${extension.name}'s provider into the templates`); await templates.addProvider(extension.name, extension.templatesProvider); diff --git a/src/pfe/portal/modules/Templates.js b/src/pfe/portal/modules/Templates.js index 38a86c1f7..e6595e9ea 100644 --- a/src/pfe/portal/modules/Templates.js +++ b/src/pfe/portal/modules/Templates.js @@ -147,12 +147,19 @@ module.exports = class Templates { /** * Add a repository to the list of template repositories. */ - async addRepository(repoUrl, repoDescription, repoName, isRepoProtected = false, isRepoEnabled = true) { + async addRepository({ + url, + name, + description, + gitCredentials, + protected: _protected = false, // the keyword 'protected' is reserved + enabled = true, + }) { this.lock(); try { const repositories = cwUtils.deepClone(this.repositoryList); - const validatedUrl = await validateRepository(repoUrl, repositories); - const newRepo = await constructRepositoryObject(validatedUrl, repoDescription, repoName, isRepoProtected, isRepoEnabled); + const validatedUrl = await validateRepository(url, repositories, gitCredentials); + const newRepo = await constructRepositoryObject(validatedUrl, description, name, _protected, enabled, gitCredentials); await addRepositoryToProviders(newRepo, this.providers); @@ -162,9 +169,9 @@ module.exports = class Templates { this.repositoryList = await updateRepoListWithReposFromProviders(this.providers, newRepositoryList, this.repositoryFile); // Fetch the repository templates and add them appropriately - const newTemplates = await getTemplatesFromRepo(newRepo) + const newTemplates = await getTemplatesFromRepo(newRepo, gitCredentials) this.allProjectTemplates = this.allProjectTemplates.concat(newTemplates); - if (isRepoEnabled) { + if (enabled) { this.enabledProjectTemplates = this.enabledProjectTemplates.concat(newTemplates); } await writeRepositoryList(this.repositoryFile, this.repositoryList); @@ -256,7 +263,7 @@ async function writeRepositoryList(repositoryFile, repositoryList) { log.info(`Repository list updated.`); } -async function validateRepository(repoUrl, repositories) { +async function validateRepository(repoUrl, repositories, gitCredentials) { let url; try { url = new URL(repoUrl).href; @@ -271,15 +278,15 @@ async function validateRepository(repoUrl, repositories) { throw new TemplateError('DUPLICATE_URL', repoUrl); } - const validJsonURL = await doesURLPointToIndexJSON(url); - if (!validJsonURL) { - throw new TemplateError('URL_DOES_NOT_POINT_TO_INDEX_JSON', url); + const templateSummaries = await getTemplateSummaries(repoUrl, gitCredentials); + if (!templateSummaries.length || templateSummaries.some(summary => !isTemplateSummary(summary))) { + throw new TemplateError('URL_DOES_NOT_POINT_TO_INDEX_JSON', repoUrl); } return url; } -async function constructRepositoryObject(url, description, name, isRepoProtected, isRepoEnabled) { +async function constructRepositoryObject(url, description, name, isRepoProtected, isRepoEnabled, gitCredentials) { let repository = { id: uuidv5(url, uuidv5.URL), name, @@ -287,7 +294,7 @@ async function constructRepositoryObject(url, description, name, isRepoProtected description, enabled: isRepoEnabled, } - repository = await fetchRepositoryDetails(repository); + repository = await fetchRepositoryDetails(repository, gitCredentials); if (isRepoProtected !== undefined) { repository.protected = isRepoProtected; } @@ -325,12 +332,12 @@ function fetchAllRepositoryDetails(repos) { ); } -async function fetchRepositoryDetails(repo) { +async function fetchRepositoryDetails(repo, gitCredentials) { let newRepo = cwUtils.deepClone(repo); // Only set the name or description of the repo if not given by the user if (!(repo.name && repo.description)){ - const repoDetails = await getNameAndDescriptionFromRepoTemplatesJSON(newRepo.url); + const repoDetails = await getNameAndDescriptionFromRepoTemplatesJSON(newRepo.url, gitCredentials); newRepo = cwUtils.updateObject(newRepo, repoDetails); } @@ -338,45 +345,29 @@ async function fetchRepositoryDetails(repo) { return newRepo; } - const templatesFromRepo = await getTemplatesFromRepo(repo); + const templatesFromRepo = await getTemplatesFromRepo(repo, gitCredentials); newRepo.projectStyles = getTemplateStyles(templatesFromRepo); return newRepo; } -async function getNameAndDescriptionFromRepoTemplatesJSON(url) { +async function getNameAndDescriptionFromRepoTemplatesJSON(url, gitCredentials) { if (!url) throw new Error(`must supply a URL`); const templatesUrl = new URL(url); // return repository untouched if repository url points to a local file - if ( templatesUrl.protocol === 'file:' ) { + if (templatesUrl.protocol === 'file:') { return {}; } - const indexPath = templatesUrl.pathname; - const templatesPath = path.dirname(indexPath) + '/' + 'templates.json'; - - templatesUrl.pathname = templatesPath; - - const options = { - host: templatesUrl.hostname, - path: templatesUrl.pathname, - port: templatesUrl.port, - method: 'GET', - } + templatesUrl.pathname = `${path.dirname(templatesUrl.pathname)}/templates.json`; - const res = await cwUtils.asyncHttpRequest(options, undefined, templatesUrl.protocol === 'https:'); + const res = await makeGetRequest(templatesUrl, gitCredentials); if (res.statusCode !== 200) { return {}; } try { - const templateDetails = JSON.parse(res.body); - const repositoryDetails = {}; - for (const prop of ['name', 'description']) { - if (templateDetails.hasOwnProperty(prop)) { - repositoryDetails[prop] = templateDetails[prop]; - } - } - return repositoryDetails; + const { name, description } = JSON.parse(res.body); + return { name, description }; } catch (error) { // Log an error but don't throw an exception as this is optional. log.error(`URL '${templatesUrl}' should return JSON`); @@ -384,6 +375,24 @@ async function getNameAndDescriptionFromRepoTemplatesJSON(url) { return {}; } +async function makeGetRequest(url, gitCredentials) { + const options = { + host: url.hostname, + path: url.pathname, + port: url.port, + method: 'GET', + } + if (gitCredentials && gitCredentials.username && gitCredentials.password) { + options.auth = `${gitCredentials.username}:${gitCredentials.password}`; + } + const res = await cwUtils.asyncHttpRequest( + options, + undefined, + url.protocol === 'https:', + ); + return res; +} + /** * Uses a template's sourceId, sourceURL or source (repo name) to get the repository for a given template * Ordered so that the source (repo name) is used as a fall back @@ -436,12 +445,12 @@ async function getTemplatesFromRepos(repos) { return newProjectTemplates; } -async function getTemplatesFromRepo(repository) { +async function getTemplatesFromRepo(repository, gitCredentials) { if (!repository.url) { throw new Error(`repo '${repository}' must have a URL`); } - const templateSummaries = await getTemplatesJSONFromURL(repository.url); + const templateSummaries = await getTemplateSummaries(repository.url, gitCredentials); const templates = templateSummaries.map(summary => { const template = { @@ -470,7 +479,7 @@ async function getTemplatesFromRepo(repository) { return templates; } -async function getTemplatesJSONFromURL(givenURL) { +async function getTemplateSummaries(givenURL, gitCredentials) { const parsedURL = new URL(givenURL); let templateSummaries; // check if repository url points to a local file and read it accordingly @@ -480,23 +489,17 @@ async function getTemplatesJSONFromURL(givenURL) { templateSummaries = await fs.readJSON(parsedURL.pathname); } } catch (err) { - throw new Error(`repo file '${parsedURL}' did not return JSON`); + throw new TemplateError('URL_DOES_NOT_POINT_TO_INDEX_JSON', null, `repo file '${parsedURL}' did not return JSON`); } } else { - const options = { - host: parsedURL.hostname, - path: parsedURL.pathname, - port: parsedURL.port, - method: 'GET', - } - const res = await cwUtils.asyncHttpRequest(options, undefined, parsedURL.protocol === 'https:'); + const res = await makeGetRequest(parsedURL, gitCredentials); if (res.statusCode !== 200) { - throw new Error(`Unexpected HTTP status for ${givenURL}: ${res.statusCode}`); + throw new TemplateError('URL_DOES_NOT_POINT_TO_INDEX_JSON', null, `Unexpected HTTP status for ${givenURL}: ${res.statusCode}`); } try { templateSummaries = JSON.parse(res.body); } catch (error) { - throw new Error(`URL '${parsedURL}' did not return JSON`); + throw new TemplateError('URL_DOES_NOT_POINT_TO_INDEX_JSON', null, `URL '${parsedURL}' did not return JSON`); } } return templateSummaries; @@ -545,19 +548,6 @@ function isRepo(obj) { return Boolean((obj && obj.hasOwnProperty('url'))); } -async function doesURLPointToIndexJSON(inputUrl) { - try { - const templateSummaries = await getTemplatesJSONFromURL(inputUrl); - if (templateSummaries.some(summary => !isTemplateSummary(summary))) { - return false; - } - } catch(error) { - log.warn(error); - return false - } - return true; -} - function isTemplateSummary(obj) { const expectedKeys = ['displayName', 'description', 'language', 'projectType', 'location', 'links']; return expectedKeys.every(key => obj.hasOwnProperty(key)); diff --git a/src/pfe/portal/routes/templates.route.js b/src/pfe/portal/routes/templates.route.js index 77fcb5426..9d3feac58 100644 --- a/src/pfe/portal/routes/templates.route.js +++ b/src/pfe/portal/routes/templates.route.js @@ -62,24 +62,22 @@ router.get('/api/v1/templates/repositories', async (req, res, _next) => { } }); -router.post('/api/v1/templates/repositories', validateReq, async (req, res, _next) => { +router.post('/api/v1/templates/repositories', validateReq, postRepositories); + +async function postRepositories(req, res, next) { const { templates: templatesController } = req.cw_user; - const repositoryName = req.sanitizeBody('name') - const repositoryUrl = req.sanitizeBody('url'); - const repositoryDescription = req.sanitizeBody('description'); - const isRepoProtected = req.sanitizeBody('protected'); - // Enabled should default to true if it isn't passed. - const isRepoEnabled = req.sanitizeBody('enabled') !== false; + const repoOptions = { + name: req.sanitizeBody('name'), + url: req.sanitizeBody('url'), + description: req.sanitizeBody('description'), + protected: req.sanitizeBody('protected'), + enabled: req.sanitizeBody('enabled') !== false, // Enabled should default to true if it isn't passed + gitCredentials: req.sanitizeBody('gitCredentials'), + }; try { - await templatesController.addRepository( - repositoryUrl, - repositoryDescription, - repositoryName, - isRepoProtected, - isRepoEnabled - ); - await sendRepositories(req, res, _next); + await templatesController.addRepository(repoOptions); + await sendRepositories(req, res, next); } catch (error) { log.error(error); const knownErrorCodes = ['INVALID_URL', 'DUPLICATE_URL', 'URL_DOES_NOT_POINT_TO_INDEX_JSON', 'ADD_TO_PROVIDER_FAILURE']; @@ -94,7 +92,7 @@ router.post('/api/v1/templates/repositories', validateReq, async (req, res, _nex } res.status(500).send(error.message); } -}); +} router.delete('/api/v1/templates/repositories', validateReq, async (req, res, _next) => { const { templates: templatesController } = req.cw_user; diff --git a/test/modules/template.service.js b/test/modules/template.service.js index 2bc28e981..3c1320816 100644 --- a/test/modules/template.service.js +++ b/test/modules/template.service.js @@ -33,6 +33,12 @@ const styledTemplates = { }, }; +// Insert your own credentials to run relevant tests +const gheCredentials = { + username: 'foo.bar@domain.com', + // password: 'INSERT_TO_RUN_RELEVANT_TESTS', +}; + const sampleRepos = { codewind: { url: templateRepositoryURL, @@ -50,6 +56,11 @@ const sampleRepos = { projectStyles: ['Codewind'], name: 'Default disabled templates', }, + GHE: { + url: 'https://raw.github.ibm.com/Richard-Waller/sampleGHETemplateRepo/415ece47958250175f182c095af7da6cfe40e58a/devfiles/index.json', + description: 'Example GHE template repository', + name: 'Example GHE template repository', + }, }; const validUrlNotPointingToIndexJson = 'https://support.oneskyapp.com/hc/en-us/article_attachments/202761627/example_1.json'; @@ -80,11 +91,11 @@ async function getTemplateRepos() { return res; } -async function addTemplateRepo(repo) { +async function addTemplateRepo(repoOptions) { const res = await reqService.chai .post('/api/v1/templates/repositories') .set('Cookie', ADMIN_COOKIE) - .send(repo); + .send(repoOptions); return res; } @@ -169,18 +180,22 @@ async function getNumberOfEnabledTemplates(queryParams) { } +const idOfImmutableRepo = 'incubator'; /** * Removes all templates repos known to PFE, and adds the supplied repos * @param {[JSON]} repoList */ async function setTemplateReposTo(repoList) { - const reposToDelete = (await getTemplateRepos()).body; - if (reposToDelete.length > 0) { + const currentRepos = (await getTemplateRepos()).body; + if (currentRepos.length > 0) { + const reposToDelete = currentRepos.filter(repo => repo.id !== idOfImmutableRepo); + console.log(reposToDelete); for (const repo of reposToDelete) { await deleteTemplateRepo(repo.url); } } - for (const repo of repoList) { + const templatesToAdd = repoList.filter(repo => repo.id !== idOfImmutableRepo); + for (const repo of templatesToAdd) { await addTemplateRepo(repo); } } @@ -241,6 +256,7 @@ module.exports = { templateRepositoryURL, sampleRepos, validUrlNotPointingToIndexJson, + gheCredentials, getTemplateRepos, addTemplateRepo, deleteTemplateRepo, diff --git a/test/src/API/templates/templates.test.js b/test/src/API/templates/templates.test.js index df9b133ce..95b8c1ff6 100644 --- a/test/src/API/templates/templates.test.js +++ b/test/src/API/templates/templates.test.js @@ -18,6 +18,7 @@ const { getDefaultTemplatesFromGithub, validUrlNotPointingToIndexJson, sampleRepos, + gheCredentials, batchPatchTemplateRepos, getTemplateRepos, getNumberOfTemplates, @@ -112,17 +113,12 @@ describe('Template API tests', function() { }); describe('a duplicate url', function() { it('should return 400', async function() { - // Arrange - const res = await getTemplateRepos(); - const originalTemplateRepos = res.body; - const duplicateRepoUrl = originalTemplateRepos[0].url; - // Act - const duplicateUrlRes = await addTemplateRepo({ - url: duplicateRepoUrl, + const { body: [existingRepo] } = await getTemplateRepos(); + const res = await addTemplateRepo({ + url: existingRepo.url, description: 'duplicate url', }); - // Assert - duplicateUrlRes.should.have.status(400, duplicateUrlRes.text); + res.should.have.status(400, res.text); }); }); describe('a valid url that does not point to an index.json', function() { @@ -167,7 +163,6 @@ describe('Template API tests', function() { originalTemplateReposLength = repos.length; originalTemplatesLength = await getNumberOfTemplates(); originalEnabledTemplatesLength = await getNumberOfEnabledTemplates(); - }); it('should add a disabled template repository giving no change to enabled templates', async function() { const addTemplateRepoRes = await addTemplateRepo(sampleRepos.disabledcodewind); @@ -180,10 +175,6 @@ describe('Template API tests', function() { afterTemplatesLength.should.be.above(originalTemplatesLength); }); }); - - - - }); }); @@ -200,19 +191,15 @@ describe('Template API tests', function() { }); describe('DELETE | GET | POST /api/v1/templates/repositories', function() { - // Save state for this test setupReposAndTemplatesForTesting(); const repo = sampleRepos.codewind; let originalTemplateRepos; let originalTemplates; - let originalNumTemplates; before(async function() { const { body: repos } = await getTemplateRepos(); originalTemplateRepos = repos; const { body: templates } = await getTemplates(); originalTemplates = templates; - originalNumTemplates = templates.length; - }); it('DELETE /api/v1/templates should remove a template repository', async function() { const res = await deleteTemplateRepo(repo.url); @@ -222,9 +209,9 @@ describe('Template API tests', function() { }); it('GET /api/v1/templates should return fewer templates', async function() { const numberOfTemplates = await getNumberOfTemplates(); - numberOfTemplates.should.be.below(originalNumTemplates); + numberOfTemplates.should.be.below(originalTemplates.length); }); - it('POST /api/v1/templates should re-add the deleted template repository', async function() { + it('POST /api/v1/templates/repositories should re-add the deleted template repository', async function() { const res = await addTemplateRepo(repo); res.should.have.status(200).and.satisfyApiSpec; res.body.should.containSubset([repo]); @@ -234,9 +221,44 @@ describe('Template API tests', function() { const res = await getTemplates(); res.should.have.status(200).and.satisfyApiSpec; res.body.should.deep.equalInAnyOrder(originalTemplates); - res.body.length.should.equal(originalNumTemplates); }); }); + + describe('Add secure template repository and list templates', function() { + before(function() { + if (!gheCredentials.password) { + this.skip(); + } + }); + + setupReposAndTemplatesForTesting(); + it('POST /api/v1/templates/repositories without GitHub credentials should fail to add a GHE template repository', async function() { + const res = await addTemplateRepo(sampleRepos.GHE); + res.should.have.status(400).and.satisfyApiSpec; + res.text.should.include(sampleRepos.GHE.url); + }); + it('POST /api/v1/templates/repositories with incorrect GHE credentials should fail to add a GHE template repository', async function() { + const res = await addTemplateRepo(sampleRepos.GHE); + res.should.have.status(400).and.satisfyApiSpec; + res.text.should.include(sampleRepos.GHE.url); + }); + it('POST /api/v1/templates/repositories with correct GHE credentials should add a GHE template repository', async function() { + const { body: originalTemplates } = await getTemplates(); + + const res = await addTemplateRepo({ + ...sampleRepos.GHE, + gitCredentials: gheCredentials, + }); + res.should.have.status(200).and.satisfyApiSpec; + res.body.should.containSubset([sampleRepos.GHE]); + + // Then GET /templates should return the templates from the repository we just added + const resToGetRequest = await getTemplates(); + resToGetRequest.should.have.status(200).and.satisfyApiSpec; + resToGetRequest.body.should.have.length.above(originalTemplates.length); + }); + }); + describe('PATCH /api/v1/batch/templates/repositories', function() { setupReposAndTemplatesForTesting(); const { url: existingRepoUrl } = sampleRepos.codewind; diff --git a/test/src/unit/template.test.js b/test/src/unit/template.test.js index 4c582803f..94e1a6e1e 100644 --- a/test/src/unit/template.test.js +++ b/test/src/unit/template.test.js @@ -27,6 +27,7 @@ const { validUrlNotPointingToIndexJson, templateRepositoryURL, getDefaultTemplatesFromGithub, + gheCredentials, } = require('../../modules/template.service'); const { suppressLogOutput } = require('../../modules/log.service'); const { testTimeout } = require('../../config'); @@ -584,7 +585,7 @@ describe('Templates.js', function() { } }); }); - describe('addRepository(repoUrl, repoDescription, repoName, isRepoProtected, isRepoEnabled)', function() { + describe('addRepository(repoOptions)', function() { const mockRepoList = [{ id: 'notanid', url: 'https://made.up/url' }]; let templateController; beforeEach(() => { @@ -599,32 +600,43 @@ describe('Templates.js', function() { describe('(, )', function() { it('throws an error', function() { const url = 'some string'; - const func = () => templateController.addRepository(url, 'description'); + const func = () => templateController.addRepository({ + url, + description: 'description', + }); return func().should.be.rejectedWith(`Invalid URL: ${url}`); }); }); describe('(, )', function() { it('throws an error', function() { const { url } = mockRepoList[0]; - const func = () => templateController.addRepository(url, 'description'); + const func = () => templateController.addRepository({ + url, + description: 'description', + }); return func().should.be.rejectedWith(`${url} is already a template repository`); }); }); describe('(, )', function() { it('throws an error', function() { const url = validUrlNotPointingToIndexJson; - const func = () => templateController.addRepository(url, 'description'); + const func = () => templateController.addRepository({ + url, + description: 'description', + }); return func().should.be.rejectedWith(`${url} does not point to a JSON file of the correct form`); }); }); describe('(, , )', function() { it('succeeds', async function() { - const func = () => templateController.addRepository(templateRepositoryURL, 'description', 'name'); - await (func().should.not.be.rejected); - templateController.repositoryList.should.containSubset([{ + const repoOptions = { url: templateRepositoryURL, - name: 'name', description: 'description', + name: 'name', + }; + await templateController.addRepository(repoOptions); + templateController.repositoryList.should.containSubset([{ + ...repoOptions, enabled: true, projectStyles: ['Codewind'], }]); @@ -636,16 +648,56 @@ describe('Templates.js', function() { }); describe('(, , )', function() { it('succeeds', async function() { - const isRepoProtected = false; - const func = () => templateController.addRepository(templateRepositoryURL, 'description', 'name', isRepoProtected); - await (func().should.not.be.rejected); - templateController.repositoryList.should.containSubset([{ + const repoOptions = { url: templateRepositoryURL, - name: 'name', description: 'description', + name: 'name', + protected: false, + }; + await templateController.addRepository(repoOptions); + templateController.repositoryList.should.containSubset([{ + ...repoOptions, enabled: true, projectStyles: ['Codewind'], + }]); + templateController.repositoryList.forEach(obj => { + obj.should.have.property('id'); + obj.id.should.be.a('string'); + }); + }); + }); + describe('(, )', function() { + it('fails', function() { + const repoOptions = { + url: sampleRepos.GHE.url, + description: 'description', + name: 'name', protected: false, + }; + const func = () => templateController.addRepository(repoOptions); + return func().should.be.rejectedWith(`Unexpected HTTP status for ${sampleRepos.GHE.url}: 404`); + }); + }); + describe('(, )', function() { + it('succeeds', async function() { + if (!gheCredentials.password) { + this.skip(); + } + const repo = { + url: sampleRepos.GHE.url, + description: 'description', + name: 'name', + protected: false, + }; + const repoOptions = { + ...repo, + gitCredentials: gheCredentials, + }; + await templateController.addRepository(repoOptions); + templateController.repositoryList.should.containSubset([{ + ...repo, + enabled: true, + projectStyles: ['Codewind'], }]); templateController.repositoryList.forEach(obj => { obj.should.have.property('id'); @@ -657,20 +709,28 @@ describe('Templates.js', function() { describe('repo with name and description in templates.json', () => { describe('(, , )', function() { it('succeeds, and allows the user to set the name and description', async function() { - const func = () => templateController.addRepository(templateRepositoryURL, 'description', 'name', false); - await (func().should.not.be.rejected); - templateController.repositoryList.should.containSubset([{ ...sampleRepos.codewind, + const repoOptions = { + url: templateRepositoryURL, name: 'name', description: 'description', - protected: false, - }]); + }; + await templateController.addRepository(repoOptions); + templateController.repositoryList.should.containSubset([repoOptions]); }); }); describe('(repo with templates.json, , , )', function() { it('succeeds, and gets the name and description from templates.json', async function() { - const func = () => templateController.addRepository(templateRepositoryURL, '', '', false); - await (func().should.not.be.rejected); - templateController.repositoryList.should.containSubset([{ ...sampleRepos.codewind, protected: false }]); + const repoOptions = { + url: templateRepositoryURL, + name: '', + description: '', + }; + await templateController.addRepository(repoOptions); + templateController.repositoryList.should.containSubset([{ + url: templateRepositoryURL, + name: sampleRepos.codewind.name, + description: sampleRepos.codewind.description, + }]); }); }); }); @@ -734,7 +794,7 @@ describe('Templates.js', function() { it('does not add a duplicate repository', async() => { const templateController = new Templates(testWorkspaceDir); templateController.repositoryList = []; - await templateController.addRepository(sampleRepos.codewind.url); + await templateController.addRepository({ url: sampleRepos.codewind.url }); templateController.repositoryList.length.should.equal(1); await templateController.addProvider('dummyProvider', provider); // Check repository has not been duplicated @@ -816,6 +876,17 @@ describe('Templates.js', function() { const validatedURL = await validateRepository(sampleRepos.codewind.url, [...mockRepoList]); validatedURL.should.equal(sampleRepos.codewind.url); }); + it('returns a validated GHE url when correct credentials are provided', async function() { + if (!gheCredentials.password) { + this.skip(); + } + const validatedURL = await validateRepository( + sampleRepos.GHE.url, + [...mockRepoList], + gheCredentials, + ); + validatedURL.should.equal(sampleRepos.GHE.url); + }); }); describe('constructRepositoryObject(url, description, name, isRepoProtected, isRepoEnabled)', () => { const constructRepositoryObject = Templates.__get__('constructRepositoryObject'); @@ -969,24 +1040,41 @@ describe('Templates.js', function() { const fetchRepositoryDetails = Templates.__get__('fetchRepositoryDetails'); it('returns the details for a repository', async() => { const details = await fetchRepositoryDetails(sampleRepos.codewind); - details.should.have.keys(['url', 'description', 'enabled', 'protected', 'projectStyles', 'name']); details.should.deep.equal(sampleRepos.codewind); }); it('returns the correct name and description for a repository from its url (json file)', async() => { - const repo = { ...sampleRepos.codewind }; - delete repo.name; - delete repo.description; - repo.should.not.have.keys(['name', 'description']); - const details = await fetchRepositoryDetails(sampleRepos.codewind); - details.should.have.keys(['url', 'description', 'enabled', 'protected', 'projectStyles', 'name']); + const { + name, // eslint-disable-line no-unused-vars + description, // eslint-disable-line no-unused-vars + ...repo + } = sampleRepos.codewind; + const details = await fetchRepositoryDetails(repo); details.should.deep.equal(sampleRepos.codewind); }); - it('returns the default "Codewind" projectStyles when it is doesn\'t exist', async() => { - const repo = { ...sampleRepos.codewind }; - delete repo.projectStyles; - repo.should.not.have.key('projectStyles'); - const details = await fetchRepositoryDetails(sampleRepos.codewind); - details.should.have.deep.property('projectStyles', ['Codewind']); + it('returns the default "Codewind" projectStyles when it is not provided', async() => { + const { + projectStyles, // eslint-disable-line no-unused-vars + ...repo + } = sampleRepos.codewind; + const details = await fetchRepositoryDetails(repo); + details.projectStyles.should.deep.equal(sampleRepos.codewind.projectStyles); + }); + it('returns all details for a GHE repository', async function() { + if (!gheCredentials.password) { + this.skip(); + } + const { + name, // eslint-disable-line no-unused-vars + description, // eslint-disable-line no-unused-vars + ...repo + } = sampleRepos.GHE; + const details = await fetchRepositoryDetails(repo, gheCredentials); + details.should.deep.equal({ + ...sampleRepos.GHE, + name: sampleRepos.codewind.name, + description: sampleRepos.codewind.description, + projectStyles: sampleRepos.codewind.projectStyles, + }); }); }); describe('getNameAndDescriptionFromRepoTemplatesJSON(url)', function() { @@ -1096,6 +1184,22 @@ describe('Templates.js', function() { const output = await getTemplatesFromRepo(sampleRepos.codewind); output.should.have.deep.members(defaultCodewindTemplates); }); + it('returns the templates from a valid GHE repository', async function() { + if (!gheCredentials.password) { + this.skip(); + } + const output = await getTemplatesFromRepo(sampleRepos.GHE, gheCredentials); + output.should.be.an('array').with.length(8); + output.forEach(obj => obj.should.have.keys([ + 'description', + 'label', + 'language', + 'projectType', + 'source', + 'sourceURL', + 'url', + ])); + }); it('throws a useful error when a string is given', function() { const func = () => getTemplatesFromRepo('string'); return func().should.be.rejectedWith(`repo 'string' must have a URL`); @@ -1104,6 +1208,10 @@ describe('Templates.js', function() { const func = () => getTemplatesFromRepo({ url: 'invalidURL' }); return func().should.be.rejectedWith('Invalid URL'); }); + it('throws a useful error when a valid GHE URL is given without credentials', function() { + const func = () => getTemplatesFromRepo({ url: sampleRepos.GHE.url }); + return func().should.be.rejectedWith(`Unexpected HTTP status for ${sampleRepos.GHE.url}: 404`); + }); it('throws a useful error when a valid URL is given but it does not point to JSON', function() { const func = () => getTemplatesFromRepo({ url: 'https://www.google.com/' }); return func().should.be.rejectedWith(`URL 'https://www.google.com/' did not return JSON`); @@ -1113,22 +1221,67 @@ describe('Templates.js', function() { return func().should.be.rejectedWith(`repo file 'file://something.json/' did not return JSON`); }); }); - describe('getTemplatesJSONFromURL(givenURL)', () => { - const getTemplatesJSONFromURL = Templates.__get__('getTemplatesJSONFromURL'); - it('gets templates JSON back from a valid URL', async() => { + describe('getTemplateSummaries(givenURL)', () => { + const getTemplateSummaries = Templates.__get__('getTemplateSummaries'); + it('gets template summaries back from a valid URL', async() => { const staticCommitURL = 'https://raw.githubusercontent.com/codewind-resources/codewind-templates/3af4928a928a5c08b07908c54799cc1675b9f965/devfiles/index.json'; - const templatesJSON = await getTemplatesJSONFromURL(staticCommitURL); - templatesJSON.should.be.an('array'); - templatesJSON.forEach(templateObject => { + const output = await getTemplateSummaries(staticCommitURL); + output.should.be.an('array'); + output.forEach(templateObject => { templateObject.should.have.keys('displayName', 'description', 'language', 'projectType', 'location', 'links'); }); }); + it('gets template summaries back from a URL to a valid JSON file', async() => { + // setup + const testFile = path.join(__dirname, 'doesURLPointToIndexJSON.json'); + const templateSummary = { + displayName: 'test', + description: 'test', + language: 'test', + projectType: 'test', + location: 'test', + links: 'test', + }; + fs.outputJSONSync(testFile, [templateSummary]); + + // test + const output = await getTemplateSummaries(path.join('file://', testFile)); + output.should.deep.equal([templateSummary]); + + // teardown + fs.removeSync(path.join(__dirname, 'doesURLPointToIndexJSON.json')); + }); + it('gets template summaries back from a valid GHE URL when valid credentials are provided', async function() { + if (!gheCredentials.password) { + this.skip(); + } + const staticCommitURL = sampleRepos.GHE.url; + const output = await getTemplateSummaries(staticCommitURL, gheCredentials); + output.should.be.an('array'); + output.forEach(templateObject => { + templateObject.should.have.keys('displayName', 'description', 'language', 'projectType', 'location', 'links', 'icon'); + }); + }); + it('should be rejected as URL is GHE but no credentials are provided', () => { + const staticCommitURL = sampleRepos.GHE.url; + const func = () => getTemplateSummaries(staticCommitURL); + return func().should.be.rejectedWith(`Unexpected HTTP status for ${staticCommitURL}: 404`); + }); + it('should be rejected as URL is GHE but credentials are invalid', () => { + const staticCommitURL = sampleRepos.GHE.url; + const incorrectGitCredentials = { + username: gheCredentials.username, + password: 'incorrectPassword', + }; + const func = () => getTemplateSummaries(staticCommitURL, incorrectGitCredentials); + return func().should.be.rejectedWith(`Unexpected HTTP status for ${staticCommitURL}: 404`); + }); it('should be rejected as URL does not point to JSON', () => { - const func = () => getTemplatesJSONFromURL('https://www.google.com/'); + const func = () => getTemplateSummaries('https://www.google.com/'); return func().should.be.rejectedWith(`URL 'https://www.google.com/' did not return JSON`); }); it('should be rejected as filepath does not exist', function() { - const func = () => getTemplatesJSONFromURL('file://something.json'); + const func = () => getTemplateSummaries('file://something.json'); return func().should.be.rejectedWith(`repo file 'file://something.json/' did not return JSON`); }); }); @@ -1239,36 +1392,6 @@ describe('Templates.js', function() { output.should.be.false; }); }); - describe('doesURLPointToIndexJSON(obj)', () => { - const doesURLPointToIndexJSON = Templates.__get__('doesURLPointToIndexJSON'); - it('returns true a valid URL is given', () => { - const { url } = sampleRepos.codewind; - return doesURLPointToIndexJSON(url).should.eventually.be.true; - }); - it('returns false as the URL does not point to JSON', function() { - return doesURLPointToIndexJSON('http://google.com').should.eventually.be.false; - }); - it('returns false as the file URL does not point to JSON', () => { - return doesURLPointToIndexJSON('file://doesNotExist').should.eventually.be.false; - }); - it('returns true as the file URL does point to JSON', () => { - const testFile = path.join(__dirname, 'doesURLPointToIndexJSON.json'); - fs.ensureFileSync(testFile); - const template = { - displayName: 'test', - description: 'test', - language: 'test', - projectType: 'test', - location: 'test', - links: 'test', - }; - fs.writeJSONSync(testFile, [template]); - return doesURLPointToIndexJSON(path.join('file://', testFile)).should.eventually.be.true; - }); - after(() => { - fs.removeSync(path.join(__dirname, 'doesURLPointToIndexJSON.json')); - }); - }); describe('isTemplateSummary(obj)', () => { const isTemplateSummary = Templates.__get__('isTemplateSummary'); const dummyTemplate = {