diff --git a/.eslintrc.yml b/.eslintrc.yml index 153b7513df785f431b9fe9adb0d982bd6cfe08a7..fb634c5880efc10a395122520458d3dde035abd2 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -30,6 +30,7 @@ overrides: 'category', 'isDeprecated', 'route', + 'auth', 'examples', '_cacheLength', 'defaultBadgeData', diff --git a/core/base-service/auth-helper.js b/core/base-service/auth-helper.js new file mode 100644 index 0000000000000000000000000000000000000000..784311d6d64dfb704d319209fb14362df897bfd5 --- /dev/null +++ b/core/base-service/auth-helper.js @@ -0,0 +1,43 @@ +'use strict' + +class AuthHelper { + constructor({ userKey, passKey, isRequired = false }, privateConfig) { + if (!userKey && !passKey) { + throw Error('Expected userKey or passKey to be set') + } + + this._userKey = userKey + this._passKey = passKey + this.user = userKey ? privateConfig[userKey] : undefined + this.pass = passKey ? privateConfig[passKey] : undefined + this.isRequired = isRequired + } + + get isConfigured() { + return ( + (this._userKey ? Boolean(this.user) : true) && + (this._passKey ? Boolean(this.pass) : true) + ) + } + + get isValid() { + if (this.isRequired) { + return this.isConfigured + } else { + const configIsEmpty = !this.user && !this.pass + return this.isConfigured || configIsEmpty + } + } + + get basicAuth() { + const { user, pass } = this + return this.isConfigured ? { user, pass } : undefined + } + + get bearerAuthHeader() { + const { pass } = this + return this.isConfigured ? { Authorization: `Bearer ${pass}` } : undefined + } +} + +module.exports = { AuthHelper } diff --git a/core/base-service/auth-helper.spec.js b/core/base-service/auth-helper.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..3bf4887cd35bb6ff65e55333eb5a1d46211a0c96 --- /dev/null +++ b/core/base-service/auth-helper.spec.js @@ -0,0 +1,96 @@ +'use strict' + +const { expect } = require('chai') +const { test, given, forCases } = require('sazerac') +const { AuthHelper } = require('./auth-helper') + +describe('AuthHelper', function() { + it('throws without userKey or passKey', function() { + expect(() => new AuthHelper({}, {})).to.throw( + Error, + 'Expected userKey or passKey to be set' + ) + }) + + describe('isValid', function() { + function validate(config, privateConfig) { + return new AuthHelper(config, privateConfig).isValid + } + test(validate, () => { + forCases([ + // Fully configured user + pass. + given( + { userKey: 'myci_user', passKey: 'myci_pass', isRequired: true }, + { myci_user: 'admin', myci_pass: 'abc123' } + ), + given( + { userKey: 'myci_user', passKey: 'myci_pass' }, + { myci_user: 'admin', myci_pass: 'abc123' } + ), + // Fully configured user or pass. + given( + { userKey: 'myci_user', isRequired: true }, + { myci_user: 'admin' } + ), + given( + { passKey: 'myci_pass', isRequired: true }, + { myci_pass: 'abc123' } + ), + given({ userKey: 'myci_user' }, { myci_user: 'admin' }), + given({ passKey: 'myci_pass' }, { myci_pass: 'abc123' }), + // Empty config. + given({ userKey: 'myci_user', passKey: 'myci_pass' }, {}), + given({ userKey: 'myci_user' }, {}), + given({ passKey: 'myci_pass' }, {}), + ]).expect(true) + + forCases([ + // Partly configured. + given( + { userKey: 'myci_user', passKey: 'myci_pass', isRequired: true }, + { myci_user: 'admin' } + ), + given( + { userKey: 'myci_user', passKey: 'myci_pass' }, + { myci_user: 'admin' } + ), + // Missing required config. + given( + { userKey: 'myci_user', passKey: 'myci_pass', isRequired: true }, + {} + ), + given({ userKey: 'myci_user', isRequired: true }, {}), + given({ passKey: 'myci_pass', isRequired: true }, {}), + ]).expect(false) + }) + }) + + describe('basicAuth', function() { + function validate(config, privateConfig) { + return new AuthHelper(config, privateConfig).basicAuth + } + test(validate, () => { + forCases([ + given( + { userKey: 'myci_user', passKey: 'myci_pass', isRequired: true }, + { myci_user: 'admin', myci_pass: 'abc123' } + ), + given( + { userKey: 'myci_user', passKey: 'myci_pass' }, + { myci_user: 'admin', myci_pass: 'abc123' } + ), + ]).expect({ user: 'admin', pass: 'abc123' }) + given({ userKey: 'myci_user' }, { myci_user: 'admin' }).expect({ + user: 'admin', + pass: undefined, + }) + given({ passKey: 'myci_pass' }, { myci_pass: 'abc123' }).expect({ + user: undefined, + pass: 'abc123', + }) + given({ userKey: 'myci_user', passKey: 'myci_pass' }, {}).expect( + undefined + ) + }) + }) +}) diff --git a/core/base-service/base.js b/core/base-service/base.js index 736ea13fc7c595b5a513536cef9c5bca896012cb..4db3ecae4abbe059393a9f75c8defa36ec51f5a2 100644 --- a/core/base-service/base.js +++ b/core/base-service/base.js @@ -4,6 +4,7 @@ const decamelize = require('decamelize') // See available emoji at http://emoji.muan.co/ const emojic = require('emojic') const Joi = require('@hapi/joi') +const { AuthHelper } = require('./auth-helper') const { assertValidCategory } = require('./categories') const checkErrorResponse = require('./check-error-response') const coalesceBadge = require('./coalesce-badge') @@ -11,6 +12,7 @@ const { NotFound, InvalidResponse, Inaccessible, + ImproperlyConfigured, InvalidParameter, Deprecated, } = require('./errors') @@ -132,6 +134,33 @@ module.exports = class BaseService { throw new Error(`Route not defined for ${this.name}`) } + /** + * Configuration for the authentication helper that prepares credentials + * for upstream requests. + * + * @abstract + * @return {object} auth + * @return {string} auth.userKey + * (Optional) The key from `privateConfig` to use as the username. + * @return {string} auth.passKey + * (Optional) The key from `privateConfig` to use as the password. + * If auth is configured, either `userKey` or `passKey` is required. + * @return {string} auth.isRequired + * (Optional) If `true`, the service will return `NotFound` unless the + * configured credentials are present. + * + * See also the config schema in `./server.js` and `doc/server-secrets.md`. + * + * To use the configured auth in the handler or fetch method, pass the + * credentials to the request. For example: + * `{ options: { auth: this.authHelper.basicAuth } }` + * `{ options: { headers: this.authHelper.bearerAuthHeader } }` + * `{ options: { qs: { token: this.authHelper.pass } } }` + */ + static get auth() { + return undefined + } + /** * Example URLs for this service. These should use the format * specified in `route`, and can be used to demonstrate how to use badges for @@ -238,8 +267,9 @@ module.exports = class BaseService { return result } - constructor({ sendAndCacheRequest }, { handleInternalErrors }) { + constructor({ sendAndCacheRequest, authHelper }, { handleInternalErrors }) { this._requestFetcher = sendAndCacheRequest + this.authHelper = authHelper this._handleInternalErrors = handleInternalErrors } @@ -303,6 +333,7 @@ module.exports = class BaseService { color: 'red', } } else if ( + error instanceof ImproperlyConfigured || error instanceof InvalidResponse || error instanceof Inaccessible || error instanceof Deprecated @@ -353,12 +384,26 @@ module.exports = class BaseService { trace.logTrace('inbound', emojic.ticket, 'Named params', namedParams) trace.logTrace('inbound', emojic.crayon, 'Query params', queryParams) - const serviceInstance = new this(context, config) + // Like the service instance, the auth helper could be reused for each request. + // However, moving its instantiation to `register()` makes `invoke()` harder + // to test. + const authHelper = this.auth + ? new AuthHelper(this.auth, config.private) + : undefined + + const serviceInstance = new this({ ...context, authHelper }, config) let serviceError + if (authHelper && !authHelper.isValid) { + const prettyMessage = authHelper.isRequired + ? 'credentials have not been configured' + : 'credentials are misconfigured' + serviceError = new ImproperlyConfigured({ prettyMessage }) + } + const { queryParamSchema } = this.route let transformedQueryParams - if (queryParamSchema) { + if (!serviceError && queryParamSchema) { try { transformedQueryParams = validate( { diff --git a/core/base-service/base.spec.js b/core/base-service/base.spec.js index 0a9297fca5b992bb4991967b6972ab2ed3f0fa63..7c0d68e1e2a4bd4dad076d0410c9c963301e2cbc 100644 --- a/core/base-service/base.spec.js +++ b/core/base-service/base.spec.js @@ -64,7 +64,7 @@ class DummyService extends BaseService { } describe('BaseService', function() { - const defaultConfig = { handleInternalErrors: false } + const defaultConfig = { handleInternalErrors: false, private: {} } it('Invokes the handler as expected', async function() { expect( @@ -482,4 +482,43 @@ describe('BaseService', function() { } }) }) + + describe('auth', function() { + class AuthService extends DummyService { + static get auth() { + return { + passKey: 'myci_pass', + isRequired: true, + } + } + + async handle() { + return { + message: `The CI password is ${this.authHelper.pass}`, + } + } + } + + it('when auth is configured properly, invoke() sets authHelper', async function() { + expect( + await AuthService.invoke( + {}, + { defaultConfig, private: { myci_pass: 'abc123' } }, + { namedParamA: 'bar.bar.bar' } + ) + ).to.deep.equal({ message: 'The CI password is abc123' }) + }) + + it('when auth is not configured properly, invoke() returns inacessible', async function() { + expect( + await AuthService.invoke({}, defaultConfig, { + namedParamA: 'bar.bar.bar', + }) + ).to.deep.equal({ + color: 'lightgray', + isError: true, + message: 'credentials have not been configured', + }) + }) + }) }) diff --git a/core/base-service/errors.js b/core/base-service/errors.js index 7c5d2aa5c116a5a8d328c722a8da9acda5e3c342..14286b1c431b32cce1594336a4117c482e3ecfe2 100644 --- a/core/base-service/errors.js +++ b/core/base-service/errors.js @@ -72,6 +72,23 @@ class Inaccessible extends ShieldsRuntimeError { } } +class ImproperlyConfigured extends ShieldsRuntimeError { + get name() { + return 'ImproperlyConfigured' + } + get defaultPrettyMessage() { + return 'improperly configured' + } + + constructor(props = {}) { + const message = props.underlyingError + ? `ImproperlyConfigured: ${props.underlyingError.message}` + : 'ImproperlyConfigured' + super(props, message) + this.response = props.response + } +} + class InvalidParameter extends ShieldsRuntimeError { get name() { return 'InvalidParameter' @@ -106,6 +123,7 @@ class Deprecated extends ShieldsRuntimeError { module.exports = { ShieldsRuntimeError, NotFound, + ImproperlyConfigured, InvalidResponse, Inaccessible, InvalidParameter, diff --git a/core/server/server.js b/core/server/server.js index 4dd1d205831995ee266bd930354352bbf5432296..1800d59dbda6dceaa32d97f9040eed9a43d561f2 100644 --- a/core/server/server.js +++ b/core/server/server.js @@ -248,6 +248,7 @@ module.exports = class Server { profiling: config.public.profiling, fetchLimitBytes: bytes(config.public.fetchLimit), rasterUrl: config.public.rasterUrl, + private: config.private, } ) ) diff --git a/dangerfile.js b/dangerfile.js index 886b18e092eb7b933b38ad8f506449f0fdf6ef31..ce5461dc5cf935504b1667d476a6019325f08f0c 100644 --- a/dangerfile.js +++ b/dangerfile.js @@ -112,10 +112,13 @@ if (allFiles.length > 100) { // eslint-disable-next-line promise/prefer-await-to-then danger.git.diffForFile(file).then(({ diff }) => { - if (diff.includes('serverSecrets') && !secretsDocs.modified) { + if ( + (diff.includes('authHelper') || diff.includes('serverSecrets')) && + !secretsDocs.modified + ) { warn( [ - `:books: Remember to ensure any changes to \`serverSecrets\` `, + `:books: Remember to ensure any changes to \`config.private\` `, `in \`${file}\` are reflected in the [server secrets documentation]`, '(https://github.com/badges/shields/blob/master/doc/server-secrets.md)', ].join('') diff --git a/server.js b/server.js index efc81f32ba1d2cbf2525d4e8e34cfb6c21850a9c..0ebdc3f9024b5c7229e9f53ccf96f507169a561f 100644 --- a/server.js +++ b/server.js @@ -1,16 +1,17 @@ 'use strict' /* eslint-disable import/order */ +const fs = require('fs') +const path = require('path') + require('dotenv').config() // Set up Sentry reporting as early in the process as possible. +const config = require('config').util.toObject() const Raven = require('raven') -const serverSecrets = require('./lib/server-secrets') - -Raven.config(process.env.SENTRY_DSN || serverSecrets.sentry_dsn).install() +Raven.config(process.env.SENTRY_DSN || config.private.sentry_dsn).install() Raven.disableConsoleAlerts() -const config = require('config').util.toObject() if (+process.argv[2]) { config.public.bind.port = +process.argv[2] } @@ -21,6 +22,14 @@ if (process.argv[3]) { console.log('Configuration:') console.dir(config.public, { depth: null }) +const legacySecretsPath = path.join(__dirname, 'private', 'secret.json') +if (fs.existsSync(legacySecretsPath)) { + console.error( + `Legacy secrets file found at ${legacySecretsPath}. It should be deleted and secrets replaced with environment variables or config/local.yml` + ) + process.exit(1) +} + const Server = require('./core/server/server') const server = (module.exports = new Server(config)) diff --git a/services/azure-devops/azure-devops-base.js b/services/azure-devops/azure-devops-base.js index 803156c6c66deda126387bb1fe95bb29f82f1464..f6d8b13fb3f4655534b88b96a0a7d85762055253 100644 --- a/services/azure-devops/azure-devops-base.js +++ b/services/azure-devops/azure-devops-base.js @@ -15,6 +15,10 @@ const latestBuildSchema = Joi.object({ }).required() module.exports = class AzureDevOpsBase extends BaseJsonService { + static get auth() { + return { passKey: 'azure_devops_token' } + } + async fetch({ url, options, schema, errorMessages }) { return this._requestJson({ schema, @@ -29,7 +33,7 @@ module.exports = class AzureDevOpsBase extends BaseJsonService { project, definitionId, branch, - headers, + auth, errorMessages ) { // Microsoft documentation: https://docs.microsoft.com/en-us/rest/api/azure/devops/build/builds/list?view=azure-devops-rest-5.0 @@ -41,7 +45,7 @@ module.exports = class AzureDevOpsBase extends BaseJsonService { statusFilter: 'completed', 'api-version': '5.0-preview.4', }, - headers, + auth, } if (branch) { diff --git a/services/azure-devops/azure-devops-coverage.service.js b/services/azure-devops/azure-devops-coverage.service.js index 9a72c5b1ff4853c08c81a388969bbd4546834fae..c084b5c6fe2323428b30cc871840811be52615a8 100644 --- a/services/azure-devops/azure-devops-coverage.service.js +++ b/services/azure-devops/azure-devops-coverage.service.js @@ -5,7 +5,7 @@ const { coveragePercentage: coveragePercentageColor, } = require('../color-formatters') const AzureDevOpsBase = require('./azure-devops-base') -const { keywords, getHeaders } = require('./azure-devops-helpers') +const { keywords } = require('./azure-devops-helpers') const documentation = ` <p> @@ -100,7 +100,7 @@ module.exports = class AzureDevOpsCoverage extends AzureDevOpsBase { } async handle({ organization, project, definitionId, branch }) { - const headers = getHeaders() + const auth = this.authHelper.basicAuth const errorMessages = { 404: 'build pipeline or coverage not found', } @@ -109,7 +109,7 @@ module.exports = class AzureDevOpsCoverage extends AzureDevOpsBase { project, definitionId, branch, - headers, + auth, errorMessages ) // Microsoft documentation: https://docs.microsoft.com/en-us/rest/api/azure/devops/test/code%20coverage/get%20build%20code%20coverage?view=azure-devops-rest-5.0 @@ -119,7 +119,7 @@ module.exports = class AzureDevOpsCoverage extends AzureDevOpsBase { buildId, 'api-version': '5.0-preview.1', }, - headers, + auth, } const json = await this.fetch({ url, diff --git a/services/azure-devops/azure-devops-helpers.js b/services/azure-devops/azure-devops-helpers.js index 2bfbc66d38e29a6e4160157644a1a1f0c24a4300..a05dabcdfbb99223a8f474114c4616c45f5e1a7d 100644 --- a/services/azure-devops/azure-devops-helpers.js +++ b/services/azure-devops/azure-devops-helpers.js @@ -1,7 +1,6 @@ 'use strict' const Joi = require('@hapi/joi') -const serverSecrets = require('../../lib/server-secrets') const { isBuildStatus } = require('../build-status') const keywords = ['vso', 'vsts', 'azure-devops'] @@ -23,15 +22,4 @@ async function fetch(serviceInstance, { url, qs = {}, errorMessages }) { return { status } } -function getHeaders() { - const headers = {} - if (serverSecrets.azure_devops_token) { - const pat = serverSecrets.azure_devops_token - const auth = Buffer.from(`:${pat}`).toString('base64') - headers.Authorization = `basic ${auth}` - } - - return headers -} - -module.exports = { keywords, fetch, getHeaders } +module.exports = { keywords, fetch } diff --git a/services/azure-devops/azure-devops-tests.service.js b/services/azure-devops/azure-devops-tests.service.js index e1b9623f68c75db7e265502bbe7661e014297e24..e0ee802cb296e060e3ba63a7c077d65196aa8782 100644 --- a/services/azure-devops/azure-devops-tests.service.js +++ b/services/azure-devops/azure-devops-tests.service.js @@ -6,7 +6,6 @@ const { renderTestResultBadge, } = require('../test-results') const AzureDevOpsBase = require('./azure-devops-base') -const { getHeaders } = require('./azure-devops-helpers') const commonAttrs = { keywords: ['vso', 'vsts', 'azure-devops'], @@ -192,7 +191,7 @@ module.exports = class AzureDevOpsTests extends AzureDevOpsBase { skipped_label: skippedLabel, } ) { - const headers = getHeaders() + const auth = this.authHelper.basicAuth const errorMessages = { 404: 'build pipeline or test result summary not found', } @@ -201,7 +200,7 @@ module.exports = class AzureDevOpsTests extends AzureDevOpsBase { project, definitionId, branch, - headers, + auth, errorMessages ) @@ -211,7 +210,7 @@ module.exports = class AzureDevOpsTests extends AzureDevOpsBase { url: `https://dev.azure.com/${organization}/${project}/_apis/test/ResultSummaryByBuild`, options: { qs: { buildId }, - headers, + auth, }, schema: buildTestResultSummarySchema, errorMessages, diff --git a/services/bintray/bintray.service.js b/services/bintray/bintray.service.js index 09e3940558536bc9af9743307f5c427c1597b13d..a32cc34de40d3a24c050a50fa286625551f8da09 100644 --- a/services/bintray/bintray.service.js +++ b/services/bintray/bintray.service.js @@ -2,7 +2,6 @@ const Joi = require('@hapi/joi') const { renderVersionBadge } = require('../version') -const serverSecrets = require('../../lib/server-secrets') const { BaseJsonService } = require('..') const schema = Joi.object() @@ -23,6 +22,10 @@ module.exports = class Bintray extends BaseJsonService { } } + static get auth() { + return { userKey: 'bintray_user', passKey: 'bintray_apikey' } + } + static get examples() { return [ { @@ -42,19 +45,11 @@ module.exports = class Bintray extends BaseJsonService { } async fetch({ subject, repo, packageName }) { - const options = {} - if (serverSecrets.bintray_user) { - options.auth = { - user: serverSecrets.bintray_user, - pass: serverSecrets.bintray_apikey, - } - } - // https://bintray.com/docs/api/#_get_version return this._requestJson({ schema, url: `https://bintray.com/api/v1/packages/${subject}/${repo}/${packageName}/versions/_latest`, - options, + options: { auth: this.authHelper.basicAuth }, }) } diff --git a/services/drone/drone-build.service.js b/services/drone/drone-build.service.js index 3375fed785a2b3896a63ebf8537342c165bd5bd5..2d2b005fd4c9646791af03da428dbb444f49d7b6 100644 --- a/services/drone/drone-build.service.js +++ b/services/drone/drone-build.service.js @@ -1,7 +1,6 @@ 'use strict' const Joi = require('@hapi/joi') -const serverSecrets = require('../../lib/server-secrets') const { isBuildStatus, renderBuildStatusBadge } = require('../build-status') const { optionalUrl } = require('../validators') const { BaseJsonService } = require('..') @@ -29,6 +28,10 @@ module.exports = class DroneBuild extends BaseJsonService { } } + static get auth() { + return { passKey: 'drone_token' } + } + static get examples() { return [ { @@ -85,11 +88,7 @@ module.exports = class DroneBuild extends BaseJsonService { qs: { ref: branch ? `refs/heads/${branch}` : undefined, }, - } - if (serverSecrets.drone_token) { - options.headers = { - Authorization: `Bearer ${serverSecrets.drone_token}`, - } + headers: this.authHelper.bearerAuthHeader, } if (!server) { server = 'https://cloud.drone.io' diff --git a/services/drone/drone-build.spec.js b/services/drone/drone-build.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..41537d050ab730e18e6627fa0f60ee6522636160 --- /dev/null +++ b/services/drone/drone-build.spec.js @@ -0,0 +1,36 @@ +'use strict' + +const { expect } = require('chai') +const nock = require('nock') +const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers') +const DroneBuild = require('./drone-build.service') + +describe('DroneBuild', function() { + cleanUpNockAfterEach() + + it('Sends auth headers to cloud instance', async function() { + const token = 'abc123' + + const scope = nock('https://cloud.drone.io', { + reqheaders: { Authorization: `Bearer abc123` }, + }) + .get(/.*/) + .reply(200, { status: 'passing' }) + + expect( + await DroneBuild.invoke( + defaultContext, + { + private: { drone_token: token }, + }, + { user: 'atlassian', repo: 'python-bitbucket' } + ) + ).to.deep.equal({ + label: undefined, + message: 'passing', + color: 'brightgreen', + }) + + scope.done() + }) +}) diff --git a/services/drone/drone-build.tester.js b/services/drone/drone-build.tester.js index 9a9f3e07da50de50073d3d01da1dcc1f15a63668..18046723c7a0587bffdc7a02e98e61bfab1726d2 100644 --- a/services/drone/drone-build.tester.js +++ b/services/drone/drone-build.tester.js @@ -3,7 +3,6 @@ const Joi = require('@hapi/joi') const { isBuildStatus } = require('../build-status') const t = (module.exports = require('../tester').createServiceTester()) -const { mockDroneCreds, token, restore } = require('./drone-test-helpers') t.create('cloud-hosted build status on default branch') .get('/drone/drone.json') @@ -27,35 +26,27 @@ t.create('cloud-hosted build status on unknown repo') }) t.create('self-hosted build status on default branch') - .before(mockDroneCreds) .get('/badges/shields.json?server=https://drone.shields.io') .intercept(nock => - nock('https://drone.shields.io/api/repos', { - reqheaders: { authorization: `Bearer ${token}` }, - }) + nock('https://drone.shields.io/api/repos') .get('/badges/shields/builds/latest') .reply(200, { status: 'success' }) ) - .finally(restore) .expectBadge({ label: 'build', message: 'passing', }) t.create('self-hosted build status on named branch') - .before(mockDroneCreds) .get( '/badges/shields/feat/awesome-thing.json?server=https://drone.shields.io' ) .intercept(nock => - nock('https://drone.shields.io/api/repos', { - reqheaders: { authorization: `Bearer ${token}` }, - }) + nock('https://drone.shields.io/api/repos') .get('/badges/shields/builds/latest') .query({ ref: 'refs/heads/feat/awesome-thing' }) .reply(200, { status: 'success' }) ) - .finally(restore) .expectBadge({ label: 'build', message: 'passing', diff --git a/services/drone/drone-test-helpers.js b/services/drone/drone-test-helpers.js deleted file mode 100644 index 8cc3c5b2c5838b56dba0e5f5141e980f1315b589..0000000000000000000000000000000000000000 --- a/services/drone/drone-test-helpers.js +++ /dev/null @@ -1,21 +0,0 @@ -'use strict' - -const sinon = require('sinon') -const serverSecrets = require('../../lib/server-secrets') - -const token = 'my-token' - -function mockDroneCreds() { - serverSecrets['drone_token'] = undefined - sinon.stub(serverSecrets, 'drone_token').value(token) -} - -function restore() { - sinon.restore() -} - -module.exports = { - token, - mockDroneCreds, - restore, -} diff --git a/services/jenkins/jenkins-base.js b/services/jenkins/jenkins-base.js index a74507438ff6b2a7665ac420bdca131db84938a5..58c9e682c987ae11d9df76e39a8cfbc38a747c3c 100644 --- a/services/jenkins/jenkins-base.js +++ b/services/jenkins/jenkins-base.js @@ -1,9 +1,15 @@ 'use strict' -const serverSecrets = require('../../lib/server-secrets') const { BaseJsonService } = require('..') module.exports = class JenkinsBase extends BaseJsonService { + static get auth() { + return { + userKey: 'jenkins_user', + passKey: 'jenkins_pass', + } + } + async fetch({ url, schema, @@ -11,18 +17,13 @@ module.exports = class JenkinsBase extends BaseJsonService { errorMessages = { 404: 'instance or job not found' }, disableStrictSSL, }) { - const options = { qs, strictSSL: disableStrictSSL === undefined } - - if (serverSecrets.jenkins_user) { - options.auth = { - user: serverSecrets.jenkins_user, - pass: serverSecrets.jenkins_pass, - } - } - return this._requestJson({ url, - options, + options: { + qs, + strictSSL: disableStrictSSL === undefined, + auth: this.authHelper.basicAuth, + }, schema, errorMessages, }) diff --git a/services/jenkins/jenkins-build.spec.js b/services/jenkins/jenkins-build.spec.js index a94054d4fee51f7b392085376cecb351a63e400c..32cb856518134ab938a36c7c0072c3349a015558 100644 --- a/services/jenkins/jenkins-build.spec.js +++ b/services/jenkins/jenkins-build.spec.js @@ -1,7 +1,10 @@ 'use strict' +const { expect } = require('chai') +const nock = require('nock') const { test, forCases, given } = require('sazerac') const { renderBuildStatusBadge } = require('../build-status') +const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers') const JenkinsBuild = require('./jenkins-build.service') describe('JenkinsBuild', function() { @@ -54,4 +57,35 @@ describe('JenkinsBuild', function() { renderBuildStatusBadge({ status: 'not built' }) ) }) + + describe('auth', function() { + cleanUpNockAfterEach() + + const user = 'admin' + const pass = 'password' + const config = { private: { jenkins_user: user, jenkins_pass: pass } } + + it('sends the auth information as configured', async function() { + const scope = nock('https://jenkins.ubuntu.com') + .get('/server/job/curtin-vmtest-daily-x/api/json?tree=color') + // This ensures that the expected credentials are actually being sent with the HTTP request. + // Without this the request wouldn't match and the test would fail. + .basicAuth({ user, pass }) + .reply(200, { color: 'blue' }) + + expect( + await JenkinsBuild.invoke(defaultContext, config, { + protocol: 'https', + host: 'jenkins.ubuntu.com', + job: 'server/job/curtin-vmtest-daily-x', + }) + ).to.deep.equal({ + label: undefined, + message: 'passing', + color: 'brightgreen', + }) + + scope.done() + }) + }) }) diff --git a/services/jenkins/jenkins-build.tester.js b/services/jenkins/jenkins-build.tester.js index 424f10e264717c8aae61b80e110b54c01e2add9d..fb3dbff938811bec8e5810456ac026e83436edcf 100644 --- a/services/jenkins/jenkins-build.tester.js +++ b/services/jenkins/jenkins-build.tester.js @@ -1,8 +1,6 @@ 'use strict' const Joi = require('@hapi/joi') -const sinon = require('sinon') -const serverSecrets = require('../../lib/server-secrets') const { isBuildStatus } = require('../build-status') const t = (module.exports = require('../tester').createServiceTester()) @@ -22,30 +20,3 @@ t.create('build found (view)') t.create('build found (job)') .get('/https/ci.eclipse.org/jgit/job/jgit.json') .expectBadge({ label: 'build', message: isJenkinsBuildStatus }) - -const user = 'admin' -const pass = 'password' - -function mockCreds() { - serverSecrets['jenkins_user'] = undefined - serverSecrets['jenkins_pass'] = undefined - sinon.stub(serverSecrets, 'jenkins_user').value(user) - sinon.stub(serverSecrets, 'jenkins_pass').value(pass) -} - -t.create('with mock credentials') - .before(mockCreds) - .get('/https/jenkins.ubuntu.com/server/job/curtin-vmtest-daily-x.json') - .intercept(nock => - nock('https://jenkins.ubuntu.com/server/job/curtin-vmtest-daily-x') - .get(`/api/json?tree=color`) - // This ensures that the expected credentials from serverSecrets are actually being sent with the HTTP request. - // Without this the request wouldn't match and the test would fail. - .basicAuth({ - user, - pass, - }) - .reply(200, { color: 'blue' }) - ) - .finally(sinon.restore) - .expectBadge({ label: 'build', message: 'passing' }) diff --git a/services/jira/jira-base.js b/services/jira/jira-base.js deleted file mode 100644 index 4f9e5f0d2c5ba948fa16f25202ef399e3eb9b64f..0000000000000000000000000000000000000000 --- a/services/jira/jira-base.js +++ /dev/null @@ -1,28 +0,0 @@ -'use strict' - -const serverSecrets = require('../../lib/server-secrets') -const { BaseJsonService } = require('..') - -module.exports = class JiraBase extends BaseJsonService { - static get category() { - return 'issue-tracking' - } - - async fetch({ url, qs, schema, errorMessages }) { - const options = { qs } - - if (serverSecrets.jira_user) { - options.auth = { - user: serverSecrets.jira_user, - pass: serverSecrets.jira_pass, - } - } - - return this._requestJson({ - schema, - url, - options, - errorMessages, - }) - } -} diff --git a/services/jira/jira-common.js b/services/jira/jira-common.js new file mode 100644 index 0000000000000000000000000000000000000000..9f51ce3fd683409015258ac6d34afe74886c8c98 --- /dev/null +++ b/services/jira/jira-common.js @@ -0,0 +1,8 @@ +'use strict' + +const authConfig = { + userKey: 'jira_user', + passKey: 'jira_pass', +} + +module.exports = { authConfig } diff --git a/services/jira/jira-issue.service.js b/services/jira/jira-issue.service.js index fe95067f418ca675956b1a79a45fe8a9704e7f91..f903f4e6f56ae5daecfd955a441dc1d49b2bdc7b 100644 --- a/services/jira/jira-issue.service.js +++ b/services/jira/jira-issue.service.js @@ -1,7 +1,8 @@ 'use strict' const Joi = require('@hapi/joi') -const JiraBase = require('./jira-base') +const { authConfig } = require('./jira-common') +const { BaseJsonService } = require('..') const schema = Joi.object({ fields: Joi.object({ @@ -14,7 +15,11 @@ const schema = Joi.object({ }).required(), }).required() -module.exports = class JiraIssue extends JiraBase { +module.exports = class JiraIssue extends BaseJsonService { + static get category() { + return 'issue-tracking' + } + static get route() { return { base: 'jira/issue', @@ -22,6 +27,10 @@ module.exports = class JiraIssue extends JiraBase { } } + static get auth() { + return authConfig + } + static get examples() { return [ { @@ -68,16 +77,15 @@ module.exports = class JiraIssue extends JiraBase { async handle({ protocol, hostAndPath, issueKey }) { // Atlassian Documentation: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-api-2-issue-issueIdOrKey-get - const url = `${protocol}://${hostAndPath}/rest/api/2/issue/${encodeURIComponent( - issueKey - )}` - const json = await this.fetch({ - url, + const json = await this._requestJson({ schema, - errorMessages: { - 404: 'issue not found', - }, + url: `${protocol}://${hostAndPath}/rest/api/2/issue/${encodeURIComponent( + issueKey + )}`, + options: { auth: this.authHelper.basicAuth }, + errorMessages: { 404: 'issue not found' }, }) + const issueStatus = json.fields.status const statusName = issueStatus.name let statusColor diff --git a/services/jira/jira-issue.spec.js b/services/jira/jira-issue.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..bccde62ef7a2e6e43d54a0da1faa7e5baef387da --- /dev/null +++ b/services/jira/jira-issue.spec.js @@ -0,0 +1,34 @@ +'use strict' + +const { expect } = require('chai') +const nock = require('nock') +const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers') +const JiraIssue = require('./jira-issue.service') +const { user, pass, config } = require('./jira-test-helpers') + +describe('JiraIssue', function() { + cleanUpNockAfterEach() + + it('sends the auth information as configured', async function() { + const scope = nock('https://myprivatejira.test') + .get(`/rest/api/2/issue/${encodeURIComponent('secure-234')}`) + // This ensures that the expected credentials are actually being sent with the HTTP request. + // Without this the request wouldn't match and the test would fail. + .basicAuth({ user, pass }) + .reply(200, { fields: { status: { name: 'in progress' } } }) + + expect( + await JiraIssue.invoke(defaultContext, config, { + protocol: 'https', + hostAndPath: 'myprivatejira.test', + issueKey: 'secure-234', + }) + ).to.deep.equal({ + label: 'secure-234', + message: 'in progress', + color: 'lightgrey', + }) + + scope.done() + }) +}) diff --git a/services/jira/jira-issue.tester.js b/services/jira/jira-issue.tester.js index 1729b9d2d3aba8938d5c2e92667ef50d2ad0fef1..edb6aab527f354e663a6a049a262aeadbec24363 100644 --- a/services/jira/jira-issue.tester.js +++ b/services/jira/jira-issue.tester.js @@ -1,13 +1,12 @@ 'use strict' const t = (module.exports = require('../tester').createServiceTester()) -const { mockJiraCreds, restore, user, pass } = require('./jira-test-helpers') -t.create('live: unknown issue') +t.create('unknown issue') .get('/https/issues.apache.org/jira/notArealIssue-000.json') .expectBadge({ label: 'jira', message: 'issue not found' }) -t.create('live: known issue') +t.create('known issue') .get('/https/issues.apache.org/jira/kafka-2896.json') .expectBadge({ label: 'kafka-2896', message: 'Resolved' }) @@ -161,26 +160,3 @@ t.create('blue-gray status color') message: 'cloudy', color: 'blue', }) - -t.create('with mock credentials') - .before(mockJiraCreds) - .get('/https/myprivatejira.com/secure-234.json') - .intercept(nock => - nock('https://myprivatejira.com/rest/api/2/issue') - .get(`/${encodeURIComponent('secure-234')}`) - // This ensures that the expected credentials from serverSecrets are actually being sent with the HTTP request. - // Without this the request wouldn't match and the test would fail. - .basicAuth({ - user, - pass, - }) - .reply(200, { - fields: { - status: { - name: 'in progress', - }, - }, - }) - ) - .finally(restore) - .expectBadge({ label: 'secure-234', message: 'in progress' }) diff --git a/services/jira/jira-sprint.service.js b/services/jira/jira-sprint.service.js index 7722477b44b58719fa1af61257554c820efa8b2a..f86b0d10a21ed628693339be72ce4cbc5ce7c360 100644 --- a/services/jira/jira-sprint.service.js +++ b/services/jira/jira-sprint.service.js @@ -1,7 +1,8 @@ 'use strict' const Joi = require('@hapi/joi') -const JiraBase = require('./jira-base') +const { authConfig } = require('./jira-common') +const { BaseJsonService } = require('..') const schema = Joi.object({ total: Joi.number(), @@ -26,7 +27,11 @@ const documentation = ` </p> ` -module.exports = class JiraSprint extends JiraBase { +module.exports = class JiraSprint extends BaseJsonService { + static get category() { + return 'issue-tracking' + } + static get route() { return { base: 'jira/sprint', @@ -34,6 +39,10 @@ module.exports = class JiraSprint extends JiraBase { } } + static get auth() { + return authConfig + } + static get examples() { return [ { @@ -79,21 +88,23 @@ module.exports = class JiraSprint extends JiraBase { // Atlassian Documentation: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-group-Search // There are other sprint-specific APIs but those require authentication. The search API // allows us to get the needed data without being forced to authenticate. - const url = `${protocol}://${hostAndPath}/rest/api/2/search` - const qs = { - jql: `sprint=${sprintId} AND type IN (Bug,Improvement,Story,"Technical task")`, - fields: 'resolution', - maxResults: 500, - } - const json = await this.fetch({ - url, + const json = await this._requestJson({ + url: `${protocol}://${hostAndPath}/rest/api/2/search`, schema, - qs, + options: { + qs: { + jql: `sprint=${sprintId} AND type IN (Bug,Improvement,Story,"Technical task")`, + fields: 'resolution', + maxResults: 500, + }, + auth: this.authHelper.basicAuth, + }, errorMessages: { 400: 'sprint not found', 404: 'sprint not found', }, }) + const numTotalIssues = json.total const numCompletedIssues = json.issues.filter(issue => { if (issue.fields.resolution != null) { diff --git a/services/jira/jira-sprint.spec.js b/services/jira/jira-sprint.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a0d92653166f9be135036e19a034b7a3817592fd --- /dev/null +++ b/services/jira/jira-sprint.spec.js @@ -0,0 +1,47 @@ +'use strict' + +const { expect } = require('chai') +const nock = require('nock') +const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers') +const JiraSprint = require('./jira-sprint.service') +const { + user, + pass, + config, + sprintId, + sprintQueryString, +} = require('./jira-test-helpers') + +describe('JiraSprint', function() { + cleanUpNockAfterEach() + + it('sends the auth information as configured', async function() { + const scope = nock('https://myprivatejira.test') + .get('/jira/rest/api/2/search') + .query(sprintQueryString) + // This ensures that the expected credentials are actually being sent with the HTTP request. + // Without this the request wouldn't match and the test would fail. + .basicAuth({ user, pass }) + .reply(200, { + total: 2, + issues: [ + { fields: { resolution: { name: 'done' } } }, + { fields: { resolution: { name: 'Unresolved' } } }, + ], + }) + + expect( + await JiraSprint.invoke(defaultContext, config, { + protocol: 'https', + hostAndPath: 'myprivatejira.test/jira', + sprintId, + }) + ).to.deep.equal({ + label: 'completion', + message: '50%', + color: 'orange', + }) + + scope.done() + }) +}) diff --git a/services/jira/jira-sprint.tester.js b/services/jira/jira-sprint.tester.js index 7813afefe6d9a9d4a95388421be97f96c8bf842a..faa8e72c5f48948a382293e6e1acd2135f471555 100644 --- a/services/jira/jira-sprint.tester.js +++ b/services/jira/jira-sprint.tester.js @@ -2,20 +2,13 @@ const t = (module.exports = require('../tester').createServiceTester()) const { isIntegerPercentage } = require('../test-validators') -const { mockJiraCreds, restore, user, pass } = require('./jira-test-helpers') +const { sprintId, sprintQueryString } = require('./jira-test-helpers') -const sprintId = 8 -const queryString = { - jql: `sprint=${sprintId} AND type IN (Bug,Improvement,Story,"Technical task")`, - fields: 'resolution', - maxResults: 500, -} - -t.create('live: unknown sprint') +t.create('unknown sprint') .get('/https/jira.spring.io/abc.json') .expectBadge({ label: 'jira', message: 'sprint not found' }) -t.create('live: known sprint') +t.create('known sprint') .get('/https/jira.spring.io/94.json') .expectBadge({ label: 'completion', @@ -27,7 +20,7 @@ t.create('100% completion') .intercept(nock => nock('http://issues.apache.org/jira/rest/api/2') .get('/search') - .query(queryString) + .query(sprintQueryString) .reply(200, { total: 2, issues: [ @@ -59,7 +52,7 @@ t.create('0% completion') .intercept(nock => nock('http://issues.apache.org/jira/rest/api/2') .get('/search') - .query(queryString) + .query(sprintQueryString) .reply(200, { total: 1, issues: [ @@ -84,7 +77,7 @@ t.create('no issues in sprint') .intercept(nock => nock('http://issues.apache.org/jira/rest/api/2') .get('/search') - .query(queryString) + .query(sprintQueryString) .reply(200, { total: 0, issues: [], @@ -101,7 +94,7 @@ t.create('issue with null resolution value') .intercept(nock => nock('https://jira.spring.io:8080/rest/api/2') .get('/search') - .query(queryString) + .query(sprintQueryString) .reply(200, { total: 2, issues: [ @@ -125,39 +118,3 @@ t.create('issue with null resolution value') message: '50%', color: 'orange', }) - -t.create('with mock credentials') - .before(mockJiraCreds) - .get(`/https/myprivatejira/jira/${sprintId}.json`) - .intercept(nock => - nock('https://myprivatejira/jira/rest/api/2') - .get('/search') - .query(queryString) - // This ensures that the expected credentials from serverSecrets are actually being sent with the HTTP request. - // Without this the request wouldn't match and the test would fail. - .basicAuth({ - user, - pass, - }) - .reply(200, { - total: 2, - issues: [ - { - fields: { - resolution: { - name: 'done', - }, - }, - }, - { - fields: { - resolution: { - name: 'Unresolved', - }, - }, - }, - ], - }) - ) - .finally(restore) - .expectBadge({ label: 'completion', message: '50%' }) diff --git a/services/jira/jira-test-helpers.js b/services/jira/jira-test-helpers.js index 291f84dfaea21dee30af9e4a719974e1bf778c33..ad31e8d2286c3346f114c980714db6ca08696b41 100644 --- a/services/jira/jira-test-helpers.js +++ b/services/jira/jira-test-helpers.js @@ -1,25 +1,20 @@ 'use strict' -const sinon = require('sinon') -const serverSecrets = require('../../lib/server-secrets') +const sprintId = 8 +const sprintQueryString = { + jql: `sprint=${sprintId} AND type IN (Bug,Improvement,Story,"Technical task")`, + fields: 'resolution', + maxResults: 500, +} const user = 'admin' const pass = 'password' - -function mockJiraCreds() { - serverSecrets['jira_user'] = undefined - serverSecrets['jira_pass'] = undefined - sinon.stub(serverSecrets, 'jira_user').value(user) - sinon.stub(serverSecrets, 'jira_pass').value(pass) -} - -function restore() { - sinon.restore() -} +const config = { private: { jira_user: user, jira_pass: pass } } module.exports = { + sprintId, + sprintQueryString, user, pass, - mockJiraCreds, - restore, + config, } diff --git a/services/nexus/nexus.service.js b/services/nexus/nexus.service.js index 8f1c506b6e0c39104a15ed82031c2befaa96b9d7..9f540f5a44ea07b44d179262eec6af894a652c4c 100644 --- a/services/nexus/nexus.service.js +++ b/services/nexus/nexus.service.js @@ -3,7 +3,6 @@ const Joi = require('@hapi/joi') const { version: versionColor } = require('../color-formatters') const { addv } = require('../text-formatters') -const serverSecrets = require('../../lib/server-secrets') const { optionalDottedVersionNClausesWithOptionalSuffix, } = require('../validators') @@ -49,6 +48,10 @@ module.exports = class Nexus extends BaseJsonService { } } + static get auth() { + return { userKey: 'nexus_user', passKey: 'nexus_pass' } + } + static get examples() { return [ { @@ -167,19 +170,10 @@ module.exports = class Nexus extends BaseJsonService { this.addQueryParamsToQueryString({ qs, queryOpt }) } - const options = { qs } - - if (serverSecrets.nexus_user) { - options.auth = { - user: serverSecrets.nexus_user, - pass: serverSecrets.nexus_pass, - } - } - const json = await this._requestJson({ schema, url, - options, + options: { qs, auth: this.authHelper.basicAuth }, errorMessages: { 404: 'artifact not found', }, diff --git a/services/nexus/nexus.spec.js b/services/nexus/nexus.spec.js index 92e5c91382e846bb970d196493041ecb586659f1..1cca34fed0a79c4d1cfa115e06c93be9d1146356 100644 --- a/services/nexus/nexus.spec.js +++ b/services/nexus/nexus.spec.js @@ -1,6 +1,8 @@ 'use strict' const { expect } = require('chai') +const nock = require('nock') +const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers') const Nexus = require('./nexus.service') const { InvalidResponse, NotFound } = require('..') @@ -68,4 +70,37 @@ describe('Nexus', function() { } }) }) + + describe('auth', function() { + cleanUpNockAfterEach() + + const user = 'admin' + const pass = 'password' + const config = { private: { nexus_user: user, nexus_pass: pass } } + + it('sends the auth information as configured', async function() { + const scope = nock('https://repository.jboss.org/nexus') + .get('/service/local/lucene/search') + .query({ g: 'jboss', a: 'jboss-client' }) + // This ensures that the expected credentials are actually being sent with the HTTP request. + // Without this the request wouldn't match and the test would fail. + .basicAuth({ user, pass }) + .reply(200, { data: [{ latestRelease: '2.3.4' }] }) + + expect( + await Nexus.invoke(defaultContext, config, { + repo: 'r', + scheme: 'https', + hostAndPath: 'repository.jboss.org/nexus', + groupId: 'jboss', + artifactId: 'jboss-client', + }) + ).to.deep.equal({ + message: 'v2.3.4', + color: 'blue', + }) + + scope.done() + }) + }) }) diff --git a/services/nexus/nexus.tester.js b/services/nexus/nexus.tester.js index 3b4ea29a93e87bca0ba5b5641852aae712fc7b5f..5d9df348adb9bb36629607e7e3085c749f547bc5 100644 --- a/services/nexus/nexus.tester.js +++ b/services/nexus/nexus.tester.js @@ -1,23 +1,11 @@ 'use strict' -const sinon = require('sinon') const { isVPlusDottedVersionNClausesWithOptionalSuffix: isVersion, } = require('../test-validators') const t = (module.exports = require('../tester').createServiceTester()) -const serverSecrets = require('../../lib/server-secrets') -const user = 'admin' -const pass = 'password' - -function mockNexusCreds() { - serverSecrets['nexus_user'] = undefined - serverSecrets['nexus_pass'] = undefined - sinon.stub(serverSecrets, 'nexus_user').value(user) - sinon.stub(serverSecrets, 'nexus_pass').value(pass) -} - -t.create('live: search release version valid artifact') +t.create('search release version valid artifact') .timeout(15000) .get('/r/https/oss.sonatype.org/com.google.guava/guava.json') .expectBadge({ @@ -25,7 +13,7 @@ t.create('live: search release version valid artifact') message: isVersion, }) -t.create('live: search release version of an nonexistent artifact') +t.create('search release version of an nonexistent artifact') .timeout(10000) .get( '/r/https/oss.sonatype.org/com.google.guava/nonexistent-artifact-id.json' @@ -35,7 +23,7 @@ t.create('live: search release version of an nonexistent artifact') message: 'artifact or version not found', }) -t.create('live: search snapshot version valid snapshot artifact') +t.create('search snapshot version valid snapshot artifact') .timeout(10000) .get('/s/https/oss.sonatype.org/com.google.guava/guava.json') .expectBadge({ @@ -43,7 +31,7 @@ t.create('live: search snapshot version valid snapshot artifact') message: isVersion, }) -t.create('live: search snapshot version of an nonexistent artifact') +t.create('search snapshot version of an nonexistent artifact') .timeout(10000) .get( '/s/https/oss.sonatype.org/com.google.guava/nonexistent-artifact-id.json' @@ -54,14 +42,14 @@ t.create('live: search snapshot version of an nonexistent artifact') color: 'red', }) -t.create('live: repository version') +t.create('repository version') .get('/developer/https/repository.jboss.org/nexus/ai.h2o/h2o-automl.json') .expectBadge({ label: 'nexus', message: isVersion, }) -t.create('live: repository version with query') +t.create('repository version with query') .get( '/fs-public-snapshots/https/repository.jboss.org/nexus/com.progress.fuse/fusehq:c=agent-apple-osx:p=tar.gz.json' ) @@ -70,7 +58,7 @@ t.create('live: repository version with query') message: isVersion, }) -t.create('live: repository version of an nonexistent artifact') +t.create('repository version of an nonexistent artifact') .get( '/developer/https/repository.jboss.org/nexus/jboss/nonexistent-artifact-id.json' ) @@ -208,25 +196,3 @@ t.create('user query params') message: 'v3.2.1', color: 'blue', }) - -t.create('auth') - .before(mockNexusCreds) - .get('/r/https/repository.jboss.org/nexus/jboss/jboss-client.json') - .intercept(nock => - nock('https://repository.jboss.org/nexus') - .get('/service/local/lucene/search') - .query({ g: 'jboss', a: 'jboss-client' }) - // This ensures that the expected credentials from serverSecrets are actually being sent with the HTTP request. - // Without this the request wouldn't match and the test would fail. - .basicAuth({ - user, - pass, - }) - .reply(200, { data: [{ latestRelease: '2.3.4' }] }) - ) - .finally(sinon.restore) - .expectBadge({ - label: 'nexus', - message: 'v2.3.4', - color: 'blue', - }) diff --git a/services/npm/npm-base.js b/services/npm/npm-base.js index 829eb1af4c10021de0e9e9cdfced945db33c3a38..d70a307fdb3b475dd8aaf626aca2deacc0316662 100644 --- a/services/npm/npm-base.js +++ b/services/npm/npm-base.js @@ -1,7 +1,6 @@ 'use strict' const Joi = require('@hapi/joi') -const serverSecrets = require('../../lib/server-secrets') const { optionalUrl } = require('../validators') const { isDependencyMap } = require('../package-json-helpers') const { BaseJsonService, InvalidResponse, NotFound } = require('..') @@ -38,6 +37,10 @@ const queryParamSchema = Joi.object({ // Abstract class for NPM badges which display data about the latest version // of a package. module.exports = class NpmBase extends BaseJsonService { + static get auth() { + return { passKey: 'npm_token' } + } + static buildRoute(base, { withTag } = {}) { if (withTag) { return { @@ -74,15 +77,16 @@ module.exports = class NpmBase extends BaseJsonService { } async _requestJson(data) { - // Use a custom Accept header because of this bug: - // <https://github.com/npm/npmjs.org/issues/163> - const headers = { Accept: '*/*' } - if (serverSecrets.npm_token) { - headers.Authorization = `Bearer ${serverSecrets.npm_token}` - } return super._requestJson({ ...data, - options: { headers }, + options: { + headers: { + // Use a custom Accept header because of this bug: + // <https://github.com/npm/npmjs.org/issues/163> + Accept: '*/*', + ...this.authHelper.bearerAuthHeader, + }, + }, }) } diff --git a/services/sonar/sonar-base.js b/services/sonar/sonar-base.js index e988995a5513ba59692f04484b4f5348f11095d7..001cd1377f3f179a7a5c1375211128bb97d765f1 100644 --- a/services/sonar/sonar-base.js +++ b/services/sonar/sonar-base.js @@ -1,11 +1,10 @@ 'use strict' const Joi = require('@hapi/joi') -const serverSecrets = require('../../lib/server-secrets') const { isLegacyVersion } = require('./sonar-helpers') const { BaseJsonService } = require('..') -const schema = Joi.object({ +const modernSchema = Joi.object({ component: Joi.object({ measures: Joi.array() .items( @@ -21,7 +20,7 @@ const schema = Joi.object({ }).required(), }).required() -const legacyApiSchema = Joi.array() +const legacySchema = Joi.array() .items( Joi.object({ msr: Joi.array() @@ -40,11 +39,16 @@ const legacyApiSchema = Joi.array() .required() module.exports = class SonarBase extends BaseJsonService { + static get auth() { + return { userKey: 'sonarqube_token' } + } + async fetch({ sonarVersion, protocol, host, component, metricName }) { - let qs, url + let qs, url, schema const useLegacyApi = isLegacyVersion({ sonarVersion }) if (useLegacyApi) { + schema = legacySchema url = `${protocol}://${host}/api/resources` qs = { resource: component, @@ -53,6 +57,7 @@ module.exports = class SonarBase extends BaseJsonService { includeTrends: true, } } else { + schema = modernSchema url = `${protocol}://${host}/api/measures/component` qs = { componentKey: component, @@ -60,18 +65,13 @@ module.exports = class SonarBase extends BaseJsonService { } } - const options = { qs } - - if (serverSecrets.sonarqube_token) { - options.auth = { - user: serverSecrets.sonarqube_token, - } - } - return this._requestJson({ - schema: useLegacyApi ? legacyApiSchema : schema, + schema, url, - options, + options: { + qs, + auth: this.authHelper.basicAuth, + }, errorMessages: { 404: 'component or metric not found, or legacy API not supported', }, diff --git a/services/sonar/sonar-fortify-rating.spec.js b/services/sonar/sonar-fortify-rating.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c935d627dda56afd80d9201a3679e7d04811719b --- /dev/null +++ b/services/sonar/sonar-fortify-rating.spec.js @@ -0,0 +1,43 @@ +'use strict' + +const { expect } = require('chai') +const nock = require('nock') +const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers') +const SonarFortifyRating = require('./sonar-fortify-rating.service') + +const token = 'abc123def456' +const config = { private: { sonarqube_token: token } } + +describe('SonarFortifyRating', function() { + cleanUpNockAfterEach() + + it('sends the auth information as configured', async function() { + const scope = nock('http://sonar.petalslink.com') + .get('/api/measures/component') + .query({ + componentKey: 'org.ow2.petals:petals-se-ase', + metricKeys: 'fortify-security-rating', + }) + // This ensures that the expected credentials are actually being sent with the HTTP request. + // Without this the request wouldn't match and the test would fail. + .basicAuth({ user: token }) + .reply(200, { + component: { + measures: [{ metric: 'fortify-security-rating', value: 4 }], + }, + }) + + expect( + await SonarFortifyRating.invoke(defaultContext, config, { + protocol: 'http', + host: 'sonar.petalslink.com', + component: 'org.ow2.petals:petals-se-ase', + }) + ).to.deep.equal({ + color: 'green', + message: '4/5', + }) + + scope.done() + }) +}) diff --git a/services/sonar/sonar-fortify-rating.tester.js b/services/sonar/sonar-fortify-rating.tester.js index dad09132c80c13418a1cfedeedfe4e325b37315b..bef8026f65a35c283687e77b89b3e0497d993997 100644 --- a/services/sonar/sonar-fortify-rating.tester.js +++ b/services/sonar/sonar-fortify-rating.tester.js @@ -1,19 +1,12 @@ 'use strict' -const sinon = require('sinon') const t = (module.exports = require('../tester').createServiceTester()) -const serverSecrets = require('../../lib/server-secrets') -const sonarToken = 'abc123def456' // The below tests are using a mocked API response because // neither SonarCloud.io nor any known public SonarQube deployments // have the Fortify plugin installed and in use, so there are no // available live endpoints to hit. t.create('Fortify Security Rating') - .before(() => { - serverSecrets['sonarqube_token'] = undefined - sinon.stub(serverSecrets, 'sonarqube_token').value(sonarToken) - }) .get( '/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/fortify-security-rating.json' ) @@ -24,7 +17,6 @@ t.create('Fortify Security Rating') componentKey: 'org.ow2.petals:petals-se-ase', metricKeys: 'fortify-security-rating', }) - .basicAuth({ user: sonarToken }) .reply(200, { component: { measures: [ @@ -36,7 +28,6 @@ t.create('Fortify Security Rating') }, }) ) - .finally(sinon.restore) .expectBadge({ label: 'fortify-security-rating', message: '4/5', diff --git a/services/symfony/symfony-insight-base.js b/services/symfony/symfony-insight-base.js index 0150cd2e5567cddbfd1087b59a24ebe0b1d9ca30..a51f7fffe0afcaeb764aecbb17187098a73f31e7 100644 --- a/services/symfony/symfony-insight-base.js +++ b/services/symfony/symfony-insight-base.js @@ -1,8 +1,7 @@ 'use strict' const Joi = require('@hapi/joi') -const serverSecrets = require('../../lib/server-secrets') -const { BaseXmlService, Inaccessible } = require('..') +const { BaseXmlService } = require('..') const violationSchema = Joi.object({ severity: Joi.equal('info', 'minor', 'major', 'critical').required(), @@ -40,10 +39,10 @@ const keywords = ['sensiolabs', 'sensio'] const gradeColors = { none: 'red', - bronze: '#C88F6A', - silver: '#C0C0C0', - gold: '#EBC760', - platinum: '#E5E4E2', + bronze: '#c88f6a', + silver: '#c0c0c0', + gold: '#ebc760', + platinum: '#e5e4e2', } class SymfonyInsightBase extends BaseXmlService { @@ -51,6 +50,14 @@ class SymfonyInsightBase extends BaseXmlService { return 'analysis' } + static get auth() { + return { + userKey: 'sl_insight_userUuid', + passKey: 'sl_insight_apiToken', + isRequired: true, + } + } + static get defaultBadgeData() { return { label: 'symfony insight', @@ -58,31 +65,15 @@ class SymfonyInsightBase extends BaseXmlService { } async fetch({ projectUuid }) { - const url = `https://insight.symfony.com/api/projects/${projectUuid}` - const options = { - headers: { - Accept: 'application/vnd.com.sensiolabs.insight+xml', - }, - } - - if ( - !serverSecrets.sl_insight_userUuid || - !serverSecrets.sl_insight_apiToken - ) { - throw new Inaccessible({ - prettyMessage: 'required API tokens not found in config', - }) - } - - options.auth = { - user: serverSecrets.sl_insight_userUuid, - pass: serverSecrets.sl_insight_apiToken, - } - return this._requestXml({ - url, - options, schema, + url: `https://insight.symfony.com/api/projects/${projectUuid}`, + options: { + headers: { + Accept: 'application/vnd.com.sensiolabs.insight+xml', + }, + auth: this.authHelper.basicAuth, + }, errorMessages: { 401: 'not authorized to access project', 404: 'project not found', diff --git a/services/symfony/symfony-insight-grade.tester.js b/services/symfony/symfony-insight-grade.tester.js index 647f6a63a4a1c83e33fafa099e604e1e5c41ff95..678e4ab7c548c7e4eeff071134d3fc5e06a32901 100644 --- a/services/symfony/symfony-insight-grade.tester.js +++ b/services/symfony/symfony-insight-grade.tester.js @@ -2,30 +2,12 @@ const Joi = require('@hapi/joi') const t = (module.exports = require('../tester').createServiceTester()) -const { - createTest, - runningMockResponse, - platinumMockResponse, - goldMockResponse, - silverMockResponse, - bronzeMockResponse, - noMedalMockResponse, - prepLiveTest, - sampleProjectUuid, - realTokenExists, - setSymfonyInsightCredsToFalsy, - restore, -} = require('./symfony-test-helpers') +const { sampleProjectUuid, checkShouldSkip } = require('./symfony-test-helpers') -createTest(t, 'live: valid project grade', { withMockCreds: false }) - .before(prepLiveTest) +t.create('valid project grade') + .skipWhen(checkShouldSkip) .get(`/${sampleProjectUuid}.json`) .timeout(15000) - .interceptIf(!realTokenExists, nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .reply(200, platinumMockResponse) - ) .expectBadge({ label: 'grade', message: Joi.equal( @@ -37,114 +19,10 @@ createTest(t, 'live: valid project grade', { withMockCreds: false }) ).required(), }) -createTest(t, 'live: nonexistent project', { withMockCreds: false }) - .before(prepLiveTest) +t.create('nonexistent project') + .skipWhen(checkShouldSkip) .get('/45afb680-d4e6-4e66-93ea-bcfa79eb8a88.json') - .interceptIf(!realTokenExists, nock => - nock('https://insight.symfony.com/api/projects') - .get('/45afb680-d4e6-4e66-93ea-bcfa79eb8a88') - .reply(404) - ) .expectBadge({ label: 'symfony insight', message: 'project not found', }) - -createTest(t, '401 not authorized grade') - .get(`/${sampleProjectUuid}.json`) - .intercept(nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .reply(401) - ) - .expectBadge({ - label: 'symfony insight', - message: 'not authorized to access project', - }) - -createTest(t, 'pending project grade') - .get(`/${sampleProjectUuid}.json`) - .intercept(nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .reply(200, runningMockResponse) - ) - .expectBadge({ - label: 'grade', - message: 'pending', - color: 'lightgrey', - }) - -createTest(t, 'platinum grade') - .get(`/${sampleProjectUuid}.json`) - .intercept(nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .reply(200, platinumMockResponse) - ) - .expectBadge({ - label: 'grade', - message: 'platinum', - color: '#e5e4e2', - }) - -createTest(t, 'gold grade') - .get(`/${sampleProjectUuid}.json`) - .intercept(nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .reply(200, goldMockResponse) - ) - .expectBadge({ - label: 'grade', - message: 'gold', - color: '#ebc760', - }) - -createTest(t, 'silver grade') - .get(`/${sampleProjectUuid}.json`) - .intercept(nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .reply(200, silverMockResponse) - ) - .expectBadge({ - label: 'grade', - message: 'silver', - color: '#c0c0c0', - }) - -createTest(t, 'bronze grade') - .get(`/${sampleProjectUuid}.json`) - .intercept(nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .reply(200, bronzeMockResponse) - ) - .expectBadge({ - label: 'grade', - message: 'bronze', - color: '#c88f6a', - }) - -createTest(t, 'no medal grade') - .get(`/${sampleProjectUuid}.json`) - .intercept(nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .reply(200, noMedalMockResponse) - ) - .expectBadge({ - label: 'grade', - message: 'no medal', - color: 'red', - }) - -createTest(t, 'auth missing', { withMockCreds: false }) - .before(setSymfonyInsightCredsToFalsy) - .get(`/${sampleProjectUuid}.json`) - .expectBadge({ - label: 'symfony insight', - message: 'required API tokens not found in config', - }) - .after(restore) diff --git a/services/symfony/symfony-insight-stars.tester.js b/services/symfony/symfony-insight-stars.tester.js index d6eac18b9208d7a100570d8c1c962547c967ef21..8d7710598193b975656e79889b9b56f9a7f2ec07 100644 --- a/services/symfony/symfony-insight-stars.tester.js +++ b/services/symfony/symfony-insight-stars.tester.js @@ -2,28 +2,12 @@ const t = (module.exports = require('../tester').createServiceTester()) const { withRegex } = require('../test-validators') -const { - createTest, - runningMockResponse, - platinumMockResponse, - goldMockResponse, - silverMockResponse, - bronzeMockResponse, - noMedalMockResponse, - prepLiveTest, - sampleProjectUuid, - realTokenExists, -} = require('./symfony-test-helpers') +const { sampleProjectUuid, checkShouldSkip } = require('./symfony-test-helpers') -createTest(t, 'live: valid project stars', { withMockCreds: false }) - .before(prepLiveTest) +t.create('valid project stars') + .skipWhen(checkShouldSkip) .get(`/${sampleProjectUuid}.json`) .timeout(15000) - .interceptIf(!realTokenExists, nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .reply(200, platinumMockResponse) - ) .expectBadge({ label: 'stars', message: withRegex( @@ -31,93 +15,10 @@ createTest(t, 'live: valid project stars', { withMockCreds: false }) ), }) -createTest(t, 'live (stars): nonexistent project', { withMockCreds: false }) - .before(prepLiveTest) +t.create('stars: nonexistent project') + .skipWhen(checkShouldSkip) .get('/abc.json') - .interceptIf(!realTokenExists, nock => - nock('https://insight.symfony.com/api/projects') - .get('/abc') - .reply(404) - ) .expectBadge({ label: 'symfony insight', message: 'project not found', }) - -createTest(t, 'pending project stars') - .get(`/${sampleProjectUuid}.json`) - .intercept(nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .reply(200, runningMockResponse) - ) - .expectBadge({ - label: 'stars', - message: 'pending', - color: 'lightgrey', - }) - -createTest(t, 'platinum stars') - .get(`/${sampleProjectUuid}.json`) - .intercept(nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .reply(200, platinumMockResponse) - ) - .expectBadge({ - label: 'stars', - message: '★★★★', - color: '#e5e4e2', - }) - -createTest(t, 'gold stars') - .get(`/${sampleProjectUuid}.json`) - .intercept(nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .reply(200, goldMockResponse) - ) - .expectBadge({ - label: 'stars', - message: '★★★☆', - color: '#ebc760', - }) - -createTest(t, 'silver stars') - .get(`/${sampleProjectUuid}.json`) - .intercept(nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .reply(200, silverMockResponse) - ) - .expectBadge({ - label: 'stars', - message: '★★☆☆', - color: '#c0c0c0', - }) - -createTest(t, 'bronze stars') - .get(`/${sampleProjectUuid}.json`) - .intercept(nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .reply(200, bronzeMockResponse) - ) - .expectBadge({ - label: 'stars', - message: '★☆☆☆', - color: '#c88f6a', - }) - -createTest(t, 'no medal stars') - .get(`/${sampleProjectUuid}.json`) - .intercept(nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .reply(200, noMedalMockResponse) - ) - .expectBadge({ - label: 'stars', - message: '☆☆☆☆', - color: 'red', - }) diff --git a/services/symfony/symfony-insight-violations.tester.js b/services/symfony/symfony-insight-violations.tester.js index 145cdd513a905c72fa01bacc22b6f034072aae88..38cdbdb0b2e9fb6c53f00b260c377d190b816e26 100644 --- a/services/symfony/symfony-insight-violations.tester.js +++ b/services/symfony/symfony-insight-violations.tester.js @@ -2,137 +2,15 @@ const t = (module.exports = require('../tester').createServiceTester()) const { withRegex } = require('../test-validators') -const { - createTest, - goldMockResponse, - runningMockResponse, - prepLiveTest, - sampleProjectUuid, - realTokenExists, - mockSymfonyUser, - mockSymfonyToken, - criticalViolation, - majorViolation, - minorViolation, - infoViolation, - multipleViolations, -} = require('./symfony-test-helpers') +const { sampleProjectUuid, checkShouldSkip } = require('./symfony-test-helpers') -createTest(t, 'live: valid project violations', { withMockCreds: false }) - .before(prepLiveTest) +t.create('valid project violations') + .skipWhen(checkShouldSkip) .get(`/${sampleProjectUuid}.json`) .timeout(15000) - .interceptIf(!realTokenExists, nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .reply(200, multipleViolations) - ) .expectBadge({ label: 'violations', message: withRegex( /\d* critical|\d* critical, \d* major|\d* critical, \d* major, \d* minor|\d* critical, \d* major, \d* minor, \d* info|\d* critical, \d* minor|\d* critical, \d* info|\d* major|\d* major, \d* minor|\d* major, \d* minor, \d* info|\d* major, \d* info|\d* minor|\d* minor, \d* info/ ), }) - -createTest(t, 'pending project grade') - .get(`/${sampleProjectUuid}.json`) - .intercept(nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .reply(200, runningMockResponse) - ) - .expectBadge({ - label: 'violations', - message: 'pending', - color: 'lightgrey', - }) - -createTest(t, 'zero violations') - .get(`/${sampleProjectUuid}.json`) - .intercept(nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .reply(200, goldMockResponse) - ) - .expectBadge({ - label: 'violations', - message: '0', - color: 'brightgreen', - }) - -createTest(t, 'critical violations') - .get(`/${sampleProjectUuid}.json`) - .intercept(nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .reply(200, criticalViolation) - ) - .expectBadge({ - label: 'violations', - message: '1 critical', - color: 'red', - }) - -createTest(t, 'major violations') - .get(`/${sampleProjectUuid}.json`) - .intercept(nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .reply(200, majorViolation) - ) - .expectBadge({ - label: 'violations', - message: '1 major', - color: 'orange', - }) - -createTest(t, 'minor violations') - .get(`/${sampleProjectUuid}.json`) - .intercept(nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .basicAuth({ - user: mockSymfonyUser, - pass: mockSymfonyToken, - }) - .reply(200, minorViolation) - ) - .expectBadge({ - label: 'violations', - message: '1 minor', - color: 'yellow', - }) - -createTest(t, 'info violations') - .get(`/${sampleProjectUuid}.json`) - .intercept(nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .basicAuth({ - user: mockSymfonyUser, - pass: mockSymfonyToken, - }) - .reply(200, infoViolation) - ) - .expectBadge({ - label: 'violations', - message: '1 info', - color: 'yellowgreen', - }) - -createTest(t, 'multiple violations grade') - .get(`/${sampleProjectUuid}.json`) - .intercept(nock => - nock('https://insight.symfony.com/api/projects') - .get(`/${sampleProjectUuid}`) - .basicAuth({ - user: mockSymfonyUser, - pass: mockSymfonyToken, - }) - .reply(200, multipleViolations) - ) - .expectBadge({ - label: 'violations', - message: '1 critical, 1 info', - color: 'red', - }) diff --git a/services/symfony/symfony-insight.spec.js b/services/symfony/symfony-insight.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..ccd61b5643f8cdc4479c9c69e242d213524ca959 --- /dev/null +++ b/services/symfony/symfony-insight.spec.js @@ -0,0 +1,255 @@ +'use strict' + +const { expect } = require('chai') +const nock = require('nock') +const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers') +const SymfonyInsightGrade = require('./symfony-insight-grade.service') +const SymfonyInsightStars = require('./symfony-insight-stars.service') +const SymfonyInsightViolations = require('./symfony-insight-violations.service') +const { + sampleProjectUuid: projectUuid, + runningMockResponse, + platinumMockResponse, + goldMockResponse, + silverMockResponse, + bronzeMockResponse, + noMedalMockResponse, + criticalViolation, + majorViolation, + minorViolation, + infoViolation, + multipleViolations, + user, + token, + config, +} = require('./symfony-test-helpers') + +// These tests are organized in a fairly unusual way because the service uses +// XML, so it's difficult to decouple the parsing from the transform + render. +// It also requires authentication so the tests must be written using a .spec +// instead of a .tester. +// +// In most other cases, do not follow this pattern. Instead, write a .spec file +// with sazerac tests of the transform and render functions. +describe('SymfonyInsight[Grade|Stars|Violation]', function() { + cleanUpNockAfterEach() + + function createMock() { + return nock('https://insight.symfony.com/api/projects') + .get(`/${projectUuid}`) + .basicAuth({ user, pass: token }) + } + + it('401 not authorized grade', async function() { + const scope = createMock().reply(401) + expect( + await SymfonyInsightGrade.invoke(defaultContext, config, { projectUuid }) + ).to.deep.equal({ + message: 'not authorized to access project', + color: 'lightgray', + isError: true, + }) + scope.done() + }) + + function testBadges({ + description, + response, + expectedGradeBadge, + expectedStarsBadge, + expectedViolationsBadge, + ...rest + }) { + if (Object.keys(rest).length > 0) { + throw Error(`Oops, what are those doing there: ${rest.join(', ')}`) + } + + describe(description, function() { + if (expectedGradeBadge) { + it('grade', async function() { + const scope = createMock().reply(200, response) + expect( + await SymfonyInsightGrade.invoke(defaultContext, config, { + projectUuid, + }) + ).to.deep.equal(expectedGradeBadge) + scope.done() + }) + } + + if (expectedStarsBadge) { + it('stars', async function() { + const scope = createMock().reply(200, response) + expect( + await SymfonyInsightStars.invoke(defaultContext, config, { + projectUuid, + }) + ).to.deep.equal(expectedStarsBadge) + scope.done() + }) + } + + if (expectedViolationsBadge) { + it('violations', async function() { + const scope = createMock().reply(200, response) + expect( + await SymfonyInsightViolations.invoke(defaultContext, config, { + projectUuid, + }) + ).to.deep.equal(expectedViolationsBadge) + scope.done() + }) + } + }) + } + + testBadges({ + description: 'pending project', + response: runningMockResponse, + expectedGradeBadge: { + label: 'grade', + message: 'pending', + color: 'lightgrey', + }, + expectedStarsBadge: { + label: 'stars', + message: 'pending', + color: 'lightgrey', + }, + expectedViolationsBadge: { + label: 'violations', + message: 'pending', + color: 'lightgrey', + }, + }) + + testBadges({ + description: 'platinum', + response: platinumMockResponse, + expectedGradeBadge: { + label: 'grade', + message: 'platinum', + color: '#e5e4e2', + }, + expectedStarsBadge: { + label: 'stars', + message: '★★★★', + color: '#e5e4e2', + }, + }) + + testBadges({ + description: 'gold', + response: goldMockResponse, + expectedGradeBadge: { + label: 'grade', + message: 'gold', + color: '#ebc760', + }, + expectedStarsBadge: { + label: 'stars', + message: '★★★☆', + color: '#ebc760', + }, + expectedViolationsBadge: { + label: 'violations', + message: '0', + color: 'brightgreen', + }, + }) + + testBadges({ + description: 'silver', + response: silverMockResponse, + expectedGradeBadge: { + label: 'grade', + message: 'silver', + color: '#c0c0c0', + }, + expectedStarsBadge: { + label: 'stars', + message: '★★☆☆', + color: '#c0c0c0', + }, + }) + + testBadges({ + description: 'bronze', + response: bronzeMockResponse, + expectedGradeBadge: { + label: 'grade', + message: 'bronze', + color: '#c88f6a', + }, + expectedStarsBadge: { + label: 'stars', + message: '★☆☆☆', + color: '#c88f6a', + }, + }) + + testBadges({ + description: 'no medal', + response: noMedalMockResponse, + expectedGradeBadge: { + label: 'grade', + message: 'no medal', + color: 'red', + }, + expectedStarsBadge: { + label: 'stars', + message: '☆☆☆☆', + color: 'red', + }, + }) + + testBadges({ + description: 'critical violations', + response: criticalViolation, + expectedViolationsBadge: { + label: 'violations', + message: '1 critical', + color: 'red', + }, + }) + + testBadges({ + description: 'major violations', + response: majorViolation, + expectedViolationsBadge: { + label: 'violations', + message: '1 major', + color: 'orange', + }, + }) + + testBadges({ + description: 'minor violations', + response: minorViolation, + expectedViolationsBadge: { + label: 'violations', + message: '1 minor', + color: 'yellow', + }, + }) + + testBadges({ + description: 'info violations', + response: infoViolation, + expectedViolationsBadge: { + label: 'violations', + message: '1 info', + color: 'yellowgreen', + }, + }) + + testBadges({ + description: 'multiple violations', + response: multipleViolations, + expectedViolationsBadge: { + label: 'violations', + message: '1 critical, 1 info', + color: 'red', + }, + }) +}) diff --git a/services/symfony/symfony-test-helpers.js b/services/symfony/symfony-test-helpers.js index 57a215b85b389c21064e550bebc088166636d4df..9030c304b0e995326470860c4e25c0b8a5d00d98 100644 --- a/services/symfony/symfony-test-helpers.js +++ b/services/symfony/symfony-test-helpers.js @@ -1,6 +1,5 @@ 'use strict' -const sinon = require('sinon') const serverSecrets = require('../../lib/server-secrets') const sampleProjectUuid = '45afb680-d4e6-4e66-93ea-bcfa79eb8a87' @@ -78,53 +77,24 @@ const multipleViolations = createMockResponse({ ], }) -const mockSymfonyUser = 'admin' -const mockSymfonyToken = 'password' -const originalUuid = serverSecrets.sl_insight_userUuid -const originalApiToken = serverSecrets.sl_insight_apiToken - -function setSymfonyInsightCredsToFalsy() { - serverSecrets['sl_insight_userUuid'] = undefined - serverSecrets['sl_insight_apiToken'] = undefined -} - -function mockSymfonyInsightCreds() { - // ensure that the fields exists before attempting to stub - setSymfonyInsightCredsToFalsy() - sinon.stub(serverSecrets, 'sl_insight_userUuid').value(mockSymfonyUser) - sinon.stub(serverSecrets, 'sl_insight_apiToken').value(mockSymfonyToken) -} - -function restore() { - sinon.restore() - serverSecrets['sl_insight_userUuid'] = originalUuid - serverSecrets['sl_insight_apiToken'] = originalApiToken +const user = 'admin' +const token = 'password' +const config = { + private: { + sl_insight_userUuid: user, + sl_insight_apiToken: token, + }, } -function prepLiveTest() { - // Since the service implementation will throw an error if the creds - // are missing, we need to ensure that creds are available for each test. - // In the case of the live tests we want to use the "real" creds if they - // exist otherwise we need to use the same stubbed creds as all the mocked tests. - if (!originalUuid) { +function checkShouldSkip() { + const noToken = + !serverSecrets.sl_insight_userUuid || !serverSecrets.sl_insight_apiToken + if (noToken) { console.warn( - 'No token provided, this test will mock Symfony Insight API responses.' + 'No Symfony credentials configured. Service tests will be skipped. Add credentials in local.yml to run these tests.' ) - mockSymfonyInsightCreds() - } -} - -function createTest( - t, - title, - { withMockCreds = true } = { withMockCreds: true } -) { - const result = t.create(title) - if (withMockCreds) { - result.before(mockSymfonyInsightCreds) - result.finally(restore) } - return result + return noToken } module.exports = { @@ -135,17 +105,13 @@ module.exports = { silverMockResponse, bronzeMockResponse, noMedalMockResponse, - mockSymfonyUser, - mockSymfonyToken, - mockSymfonyInsightCreds, - setSymfonyInsightCredsToFalsy, - restore, - realTokenExists: originalUuid, - prepLiveTest, criticalViolation, majorViolation, minorViolation, infoViolation, multipleViolations, - createTest, + user, + token, + config, + checkShouldSkip, } diff --git a/services/teamcity/teamcity-base.js b/services/teamcity/teamcity-base.js index 9e3d5d8423ffa5f0d951ae866aa2260f3513b4f9..6a9cdcebda672a48017dff75b310d327880e4ec1 100644 --- a/services/teamcity/teamcity-base.js +++ b/services/teamcity/teamcity-base.js @@ -1,9 +1,12 @@ 'use strict' -const serverSecrets = require('../../lib/server-secrets') const { BaseJsonService } = require('..') module.exports = class TeamCityBase extends BaseJsonService { + static get auth() { + return { userKey: 'teamcity_user', passKey: 'teamcity_pass' } + } + async fetch({ protocol, hostAndPath, @@ -17,28 +20,20 @@ module.exports = class TeamCityBase extends BaseJsonService { protocol = 'https' hostAndPath = 'teamcity.jetbrains.com' } - const url = `${protocol}://${hostAndPath}/${apiPath}` - const options = { qs } // JetBrains API Auth Docs: https://confluence.jetbrains.com/display/TCD18/REST+API#RESTAPI-RESTAuthentication - if (serverSecrets.teamcity_user) { - options.auth = { - user: serverSecrets.teamcity_user, - pass: serverSecrets.teamcity_pass, - } + const options = { qs } + const auth = this.authHelper.basicAuth + if (auth) { + options.auth = auth } else { qs.guest = 1 } - const defaultErrorMessages = { - 404: 'build not found', - } - const errors = { ...defaultErrorMessages, ...errorMessages } - return this._requestJson({ - url, + url: `${protocol}://${hostAndPath}/${apiPath}`, schema, options, - errorMessages: errors, + errorMessages: { 404: 'build not found', ...errorMessages }, }) } } diff --git a/services/teamcity/teamcity-build.spec.js b/services/teamcity/teamcity-build.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..6823b8e899b2c39171fab19bbfbb922a4ac2781f --- /dev/null +++ b/services/teamcity/teamcity-build.spec.js @@ -0,0 +1,38 @@ +'use strict' + +const { expect } = require('chai') +const nock = require('nock') +const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers') +const TeamCityBuild = require('./teamcity-build.service') +const { user, pass, config } = require('./teamcity-test-helpers') + +describe('TeamCityBuild', function() { + cleanUpNockAfterEach() + + it('sends the auth information as configured', async function() { + const scope = nock('https://mycompany.teamcity.com') + .get(`/app/rest/builds/${encodeURIComponent('buildType:(id:bt678)')}`) + // This ensures that the expected credentials are actually being sent with the HTTP request. + // Without this the request wouldn't match and the test would fail. + .basicAuth({ user, pass }) + .reply(200, { + status: 'FAILURE', + statusText: + 'Tests failed: 1 (1 new), passed: 50246, ignored: 1, muted: 12', + }) + + expect( + await TeamCityBuild.invoke(defaultContext, config, { + protocol: 'https', + hostAndPath: 'mycompany.teamcity.com', + verbosity: 'e', + buildId: 'bt678', + }) + ).to.deep.equal({ + message: 'tests failed: 1 (1 new), passed: 50246, ignored: 1, muted: 12', + color: 'red', + }) + + scope.done() + }) +}) diff --git a/services/teamcity/teamcity-build.tester.js b/services/teamcity/teamcity-build.tester.js index 21e47fb7e063ad902148e9cd7b7cb5a7b042753e..737bee09e7c3f2fb9f617d22e57e2111012ef257 100644 --- a/services/teamcity/teamcity-build.tester.js +++ b/services/teamcity/teamcity-build.tester.js @@ -3,35 +3,29 @@ const Joi = require('@hapi/joi') const { withRegex } = require('../test-validators') const t = (module.exports = require('../tester').createServiceTester()) -const { - mockTeamCityCreds, - pass, - user, - restore, -} = require('./teamcity-test-helpers') const buildStatusValues = Joi.equal('passing', 'failure', 'error').required() const buildStatusTextRegex = /^success|failure|error|tests( failed: \d+( \(\d+ new\))?)?(,)?( passed: \d+)?(,)?( ignored: \d+)?(,)?( muted: \d+)?/ -t.create('live: codebetter unknown build') +t.create('codebetter unknown build') .get('/codebetter/btabc.json') .expectBadge({ label: 'build', message: 'build not found' }) -t.create('live: codebetter known build') +t.create('codebetter known build') .get('/codebetter/IntelliJIdeaCe_JavaDecompilerEngineTests.json') .expectBadge({ label: 'build', message: buildStatusValues, }) -t.create('live: simple status for known build') +t.create('simple status for known build') .get('/https/teamcity.jetbrains.com/s/bt345.json') .expectBadge({ label: 'build', message: buildStatusValues, }) -t.create('live: full status for known build') +t.create('full status for known build') .get('/https/teamcity.jetbrains.com/e/bt345.json') .expectBadge({ label: 'build', @@ -139,29 +133,3 @@ t.create('full build status with failed build') message: 'tests failed: 10 (2 new), passed: 99', color: 'red', }) - -t.create('with auth') - .before(mockTeamCityCreds) - .get('/https/selfhosted.teamcity.com/e/bt678.json') - .intercept(nock => - nock('https://selfhosted.teamcity.com/app/rest/builds') - .get(`/${encodeURIComponent('buildType:(id:bt678)')}`) - .query({}) - // This ensures that the expected credentials from serverSecrets are actually being sent with the HTTP request. - // Without this the request wouldn't match and the test would fail. - .basicAuth({ - user, - pass, - }) - .reply(200, { - status: 'FAILURE', - statusText: - 'Tests failed: 1 (1 new), passed: 50246, ignored: 1, muted: 12', - }) - ) - .finally(restore) - .expectBadge({ - label: 'build', - message: 'tests failed: 1 (1 new), passed: 50246, ignored: 1, muted: 12', - color: 'red', - }) diff --git a/services/teamcity/teamcity-coverage.spec.js b/services/teamcity/teamcity-coverage.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a624187ce044549dd53f8a0b7e11fc3dbd656d2c --- /dev/null +++ b/services/teamcity/teamcity-coverage.spec.js @@ -0,0 +1,43 @@ +'use strict' + +const { expect } = require('chai') +const nock = require('nock') +const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers') +const TeamCityCoverage = require('./teamcity-coverage.service') +const { user, pass, config } = require('./teamcity-test-helpers') + +describe('TeamCityCoverage', function() { + cleanUpNockAfterEach() + + it('sends the auth information as configured', async function() { + const scope = nock('https://mycompany.teamcity.com') + .get( + `/app/rest/builds/${encodeURIComponent( + 'buildType:(id:bt678)' + )}/statistics` + ) + .query({}) + // This ensures that the expected credentials are actually being sent with the HTTP request. + // Without this the request wouldn't match and the test would fail. + .basicAuth({ user, pass }) + .reply(200, { + property: [ + { name: 'CodeCoverageAbsSCovered', value: '82' }, + { name: 'CodeCoverageAbsSTotal', value: '100' }, + ], + }) + + expect( + await TeamCityCoverage.invoke(defaultContext, config, { + protocol: 'https', + hostAndPath: 'mycompany.teamcity.com', + buildId: 'bt678', + }) + ).to.deep.equal({ + message: '82%', + color: 'yellowgreen', + }) + + scope.done() + }) +}) diff --git a/services/teamcity/teamcity-coverage.tester.js b/services/teamcity/teamcity-coverage.tester.js index 2f70039f1f9b59124bb2f0578a9ff950d5259ad2..b7412db2ce6e48b23fbc6c5d3d02215d5be92a08 100644 --- a/services/teamcity/teamcity-coverage.tester.js +++ b/services/teamcity/teamcity-coverage.tester.js @@ -2,32 +2,26 @@ const { isIntegerPercentage } = require('../test-validators') const t = (module.exports = require('../tester').createServiceTester()) -const { - mockTeamCityCreds, - pass, - user, - restore, -} = require('./teamcity-test-helpers') -t.create('live: valid buildId') +t.create('valid buildId') .get('/ReactJSNet_PullRequests.json') .expectBadge({ label: 'coverage', message: isIntegerPercentage, }) -t.create('live: specified instance valid buildId') +t.create('specified instance valid buildId') .get('/https/teamcity.jetbrains.com/ReactJSNet_PullRequests.json') .expectBadge({ label: 'coverage', message: isIntegerPercentage, }) -t.create('live: invalid buildId') +t.create('invalid buildId') .get('/btABC999.json') .expectBadge({ label: 'coverage', message: 'build not found' }) -t.create('live: specified instance invalid buildId') +t.create('specified instance invalid buildId') .get('/https/teamcity.jetbrains.com/btABC000.json') .expectBadge({ label: 'coverage', message: 'build not found' }) @@ -75,36 +69,3 @@ t.create('zero lines covered') message: '0%', color: 'red', }) - -t.create('with auth, lines covered') - .before(mockTeamCityCreds) - .get('/https/selfhosted.teamcity.com/bt678.json') - .intercept(nock => - nock('https://selfhosted.teamcity.com/app/rest/builds') - .get(`/${encodeURIComponent('buildType:(id:bt678)')}/statistics`) - .query({}) - // This ensures that the expected credentials from serverSecrets are actually being sent with the HTTP request. - // Without this the request wouldn't match and the test would fail. - .basicAuth({ - user, - pass, - }) - .reply(200, { - property: [ - { - name: 'CodeCoverageAbsSCovered', - value: '82', - }, - { - name: 'CodeCoverageAbsSTotal', - value: '100', - }, - ], - }) - ) - .finally(restore) - .expectBadge({ - label: 'coverage', - message: '82%', - color: 'yellowgreen', - }) diff --git a/services/teamcity/teamcity-test-helpers.js b/services/teamcity/teamcity-test-helpers.js index 1aa7c1a21b94c0b536356e0a24b8dc49f41ff711..47cfe75a1df64e540048f6ee510444a4011cccac 100644 --- a/services/teamcity/teamcity-test-helpers.js +++ b/services/teamcity/teamcity-test-helpers.js @@ -1,25 +1,11 @@ 'use strict' -const sinon = require('sinon') -const serverSecrets = require('../../lib/server-secrets') - const user = 'admin' const pass = 'password' - -function mockTeamCityCreds() { - serverSecrets['teamcity_user'] = undefined - serverSecrets['teamcity_pass'] = undefined - sinon.stub(serverSecrets, 'teamcity_user').value(user) - sinon.stub(serverSecrets, 'teamcity_pass').value(pass) -} - -function restore() { - sinon.restore() -} +const config = { private: { teamcity_user: user, teamcity_pass: pass } } module.exports = { user, pass, - mockTeamCityCreds, - restore, + config, } diff --git a/services/test-helpers.js b/services/test-helpers.js new file mode 100644 index 0000000000000000000000000000000000000000..656803b2d734637f5c2891c36d1ade544252ae1d --- /dev/null +++ b/services/test-helpers.js @@ -0,0 +1,24 @@ +'use strict' + +const nock = require('nock') +const request = require('request') +const { promisify } = require('../core/base-service/legacy-request-handler') + +function cleanUpNockAfterEach() { + afterEach(function() { + nock.restore() + nock.cleanAll() + nock.enableNetConnect() + nock.activate() + }) +} + +const sendAndCacheRequest = promisify(request) + +const defaultContext = { sendAndCacheRequest } + +module.exports = { + cleanUpNockAfterEach, + sendAndCacheRequest, + defaultContext, +} diff --git a/services/wheelmap/wheelmap.service.js b/services/wheelmap/wheelmap.service.js index 1c16724a6d306c651d894409c31c31593da81ee8..b3702f0a9aba87399c5f59fefcb6f0ca1e1fe631 100644 --- a/services/wheelmap/wheelmap.service.js +++ b/services/wheelmap/wheelmap.service.js @@ -1,10 +1,9 @@ 'use strict' const Joi = require('@hapi/joi') -const serverSecrets = require('../../lib/server-secrets') const { BaseJsonService } = require('..') -const wheelmapSchema = Joi.object({ +const schema = Joi.object({ node: Joi.object({ wheelchair: Joi.string().required(), }).required(), @@ -22,6 +21,10 @@ module.exports = class Wheelmap extends BaseJsonService { } } + static get auth() { + return { passKey: 'wheelmap_token', isRequired: true } + } + static get examples() { return [ { @@ -49,19 +52,10 @@ module.exports = class Wheelmap extends BaseJsonService { } async fetch({ nodeId }) { - let options - if (serverSecrets.wheelmap_token) { - options = { - qs: { - api_key: `${serverSecrets.wheelmap_token}`, - }, - } - } - return this._requestJson({ - schema: wheelmapSchema, + schema, url: `https://wheelmap.org/api/nodes/${nodeId}`, - options, + options: { qs: { api_key: this.authHelper.pass } }, errorMessages: { 401: 'invalid token', 404: 'node not found', diff --git a/services/wheelmap/wheelmap.spec.js b/services/wheelmap/wheelmap.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..d406aeceb559ced3f7cb6d137dcaf211375d441d --- /dev/null +++ b/services/wheelmap/wheelmap.spec.js @@ -0,0 +1,61 @@ +'use strict' + +const { expect } = require('chai') +const nock = require('nock') +const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers') +const Wheelmap = require('./wheelmap.service') + +describe('Wheelmap', function() { + cleanUpNockAfterEach() + + const token = 'abc123' + const config = { private: { wheelmap_token: token } } + + function createMock({ nodeId, wheelchair }) { + const scope = nock('https://wheelmap.org') + .get(`/api/nodes/${nodeId}`) + .query({ api_key: token }) + + if (wheelchair) { + return scope.reply(200, { node: { wheelchair } }) + } else { + return scope.reply(404) + } + } + + it('node with accessibility', async function() { + const nodeId = '26699541' + const scope = createMock({ nodeId, wheelchair: 'yes' }) + expect( + await Wheelmap.invoke(defaultContext, config, { nodeId }) + ).to.deep.equal({ message: 'yes', color: 'brightgreen' }) + scope.done() + }) + + it('node with limited accessibility', async function() { + const nodeId = '2034868974' + const scope = createMock({ nodeId, wheelchair: 'limited' }) + expect( + await Wheelmap.invoke(defaultContext, config, { nodeId }) + ).to.deep.equal({ message: 'limited', color: 'yellow' }) + scope.done() + }) + + it('node without accessibility', async function() { + const nodeId = '-147495158' + const scope = createMock({ nodeId, wheelchair: 'no' }) + expect( + await Wheelmap.invoke(defaultContext, config, { nodeId }) + ).to.deep.equal({ message: 'no', color: 'red' }) + scope.done() + }) + + it('node not found', async function() { + const nodeId = '0' + const scope = createMock({ nodeId }) + expect( + await Wheelmap.invoke(defaultContext, config, { nodeId }) + ).to.deep.equal({ message: 'node not found', color: 'red', isError: true }) + scope.done() + }) +}) diff --git a/services/wheelmap/wheelmap.tester.js b/services/wheelmap/wheelmap.tester.js index 09470a92ff87b37066a26fa0487136639fea69dd..051bb31140ec44226a39ae8bd007a96dbe78ca83 100644 --- a/services/wheelmap/wheelmap.tester.js +++ b/services/wheelmap/wheelmap.tester.js @@ -3,31 +3,20 @@ const serverSecrets = require('../../lib/server-secrets') const t = (module.exports = require('../tester').createServiceTester()) -const noToken = !serverSecrets.wheelmap_token -function logTokenWarning() { +function checkShouldSkip() { + const noToken = !serverSecrets.wheelmap_token if (noToken) { console.warn( - "No token provided, this test will mock Wheelmap's API responses." + 'No Wheelmap token configured. Service tests will be skipped. Add a token in local.yml to run these tests.' ) } + return noToken } t.create('node with accessibility') - .before(logTokenWarning) + .skipWhen(checkShouldSkip) .get('/26699541.json') .timeout(7500) - .interceptIf(noToken, nock => - nock('https://wheelmap.org/') - .get('/api/nodes/26699541') - .reply( - 200, - JSON.stringify({ - node: { - wheelchair: 'yes', - }, - }) - ) - ) .expectBadge({ label: 'accessibility', message: 'yes', @@ -35,21 +24,9 @@ t.create('node with accessibility') }) t.create('node with limited accessibility') - .before(logTokenWarning) + .skipWhen(checkShouldSkip) .get('/2034868974.json') .timeout(7500) - .interceptIf(noToken, nock => - nock('https://wheelmap.org/') - .get('/api/nodes/2034868974') - .reply( - 200, - JSON.stringify({ - node: { - wheelchair: 'limited', - }, - }) - ) - ) .expectBadge({ label: 'accessibility', message: 'limited', @@ -57,21 +34,9 @@ t.create('node with limited accessibility') }) t.create('node without accessibility') - .before(logTokenWarning) + .skipWhen(checkShouldSkip) .get('/-147495158.json') .timeout(7500) - .interceptIf(noToken, nock => - nock('https://wheelmap.org/') - .get('/api/nodes/-147495158') - .reply( - 200, - JSON.stringify({ - node: { - wheelchair: 'no', - }, - }) - ) - ) .expectBadge({ label: 'accessibility', message: 'no', @@ -79,14 +44,9 @@ t.create('node without accessibility') }) t.create('node not found') - .before(logTokenWarning) + .skipWhen(checkShouldSkip) .get('/0.json') .timeout(7500) - .interceptIf(noToken, nock => - nock('https://wheelmap.org/') - .get('/api/nodes/0') - .reply(404) - ) .expectBadge({ label: 'accessibility', message: 'node not found',