diff --git a/services/sensiolabs/sensiolabs.service.js b/services/sensiolabs/sensiolabs.service.js deleted file mode 100644 index 75c97096ea888..0000000000000 --- a/services/sensiolabs/sensiolabs.service.js +++ /dev/null @@ -1,110 +0,0 @@ -'use strict' - -const LegacyService = require('../legacy-service') -const { makeBadgeData: getBadgeData } = require('../../lib/badge-data') -const serverSecrets = require('../../lib/server-secrets') - -// This legacy service should be rewritten to use e.g. BaseJsonService. -// -// Tips for rewriting: -// https://github.com/badges/shields/blob/master/doc/rewriting-services.md -// -// Do not base new services on this code. -module.exports = class Sensiolabs extends LegacyService { - static get category() { - return 'build' - } - - static get route() { - return { - base: 'sensiolabs', - } - } - - static get examples() { - return [ - { - title: 'SensioLabs Insight', - previewUrl: 'i/45afb680-d4e6-4e66-93ea-bcfa79eb8a87', - }, - ] - } - - static registerLegacyRouteHandler({ camp, cache }) { - camp.route( - /^\/sensiolabs\/i\/([^/]+)\.(svg|png|gif|jpg|json)$/, - cache((data, match, sendBadge, request) => { - const projectUuid = match[1] - const format = match[2] - const options = { - method: 'GET', - uri: `https://insight.sensiolabs.com/api/projects/${projectUuid}`, - headers: { - Accept: 'application/vnd.com.sensiolabs.insight+xml', - }, - } - - if (serverSecrets.sl_insight_userUuid) { - options.auth = { - user: serverSecrets.sl_insight_userUuid, - pass: serverSecrets.sl_insight_apiToken, - } - } - - const badgeData = getBadgeData('check', data) - - request(options, (err, res, body) => { - if (err != null || res.statusCode !== 200) { - badgeData.text[1] = 'inaccessible' - sendBadge(format, badgeData) - return - } - - const matchStatus = body.match( - /<\/status>/im - ) - const matchGrade = body.match( - /<\/grade>/im - ) - - if (matchStatus === null) { - badgeData.text[1] = 'inaccessible' - sendBadge(format, badgeData) - return - } else if (matchStatus[1] !== 'finished') { - badgeData.text[1] = 'pending' - sendBadge(format, badgeData) - return - } else if (matchGrade === null) { - badgeData.text[1] = 'invalid' - sendBadge(format, badgeData) - return - } - - if (matchGrade[1] === 'platinum') { - badgeData.text[1] = 'platinum' - badgeData.colorscheme = 'brightgreen' - } else if (matchGrade[1] === 'gold') { - badgeData.text[1] = 'gold' - badgeData.colorscheme = 'yellow' - } else if (matchGrade[1] === 'silver') { - badgeData.text[1] = 'silver' - badgeData.colorscheme = 'lightgrey' - } else if (matchGrade[1] === 'bronze') { - badgeData.text[1] = 'bronze' - badgeData.colorscheme = 'orange' - } else if (matchGrade[1] === 'none') { - badgeData.text[1] = 'no medal' - badgeData.colorscheme = 'red' - } else { - badgeData.text[1] = 'invalid' - sendBadge(format, badgeData) - return - } - - sendBadge(format, badgeData) - }) - }) - ) - } -} diff --git a/services/symfony/symfony-insight.service.js b/services/symfony/symfony-insight.service.js new file mode 100644 index 0000000000000..280ca692f871f --- /dev/null +++ b/services/symfony/symfony-insight.service.js @@ -0,0 +1,289 @@ +'use strict' + +const Joi = require('joi') +const BaseXmlService = require('../base-xml') +const serverSecrets = require('../../lib/server-secrets') +const { Inaccessible } = require('../errors') + +const violationSchema = Joi.object({ + severity: Joi.equal('info', 'minor', 'major', 'critical').required(), +}).required() + +const schema = Joi.object({ + project: Joi.object({ + 'last-analysis': Joi.object({ + status: Joi.equal( + 'ordered', + 'running', + 'measured', + 'analyzed', + 'finished' + ).required(), + grade: Joi.equal('platinum', 'gold', 'silver', 'bronze', 'none'), + violations: Joi.object({ + // RE: https://github.com/NaturalIntelligence/fast-xml-parser/issues/68 + // The BaseXmlService uses the fast-xml-parser which doesn't support forcing + // the xml nodes to always be parsed as an array. Currently, if the response + // only contains a single violation then it will be parsed as an object, + // otherwise it will be parsed as an array. + violation: Joi.array() + .items(violationSchema) + .single() + .required(), + }), + }), + }).required(), +}).required() + +const keywords = ['sensiolabs'] + +module.exports = class SymfonyInsight extends BaseXmlService { + static render({ + metric, + status, + grade, + numViolations, + numCriticalViolations, + numMajorViolations, + numMinorViolations, + numInfoViolations, + }) { + if (status !== 'finished') { + return { + label: metric, + message: 'pending', + color: 'lightgrey', + } + } + + if (metric === 'grade') { + return this.renderGradeBadge({ grade }) + } else { + return this.renderViolationsBadge({ + numViolations, + numCriticalViolations, + numMajorViolations, + numMinorViolations, + numInfoViolations, + }) + } + } + + static renderGradeBadge({ grade }) { + let color, + message = grade + if (grade === 'platinum') { + color = '#E5E4E2' + } else if (grade === 'gold') { + color = '#EBC760' + } else if (grade === 'silver') { + color = '#C0C0C0' + } else if (grade === 'bronze') { + color = '#C88F6A' + } else { + message = 'no medal' + color = 'red' + } + + return { + label: 'grade', + message, + color, + } + } + + static renderViolationsBadge({ + numViolations, + numCriticalViolations, + numMajorViolations, + numMinorViolations, + numInfoViolations, + }) { + if (numViolations === 0) { + return { + label: 'violations', + message: '0', + color: 'brightgreen', + } + } + + let color = 'yellowgreen' + const violationSummary = [] + + if (numInfoViolations > 0) { + violationSummary.push(`${numInfoViolations} info`) + } + if (numMinorViolations > 0) { + violationSummary.unshift(`${numMinorViolations} minor`) + color = 'yellow' + } + if (numMajorViolations > 0) { + violationSummary.unshift(`${numMajorViolations} major`) + color = 'orange' + } + if (numCriticalViolations > 0) { + violationSummary.unshift(`${numCriticalViolations} critical`) + color = 'red' + } + + return { + label: 'violations', + message: violationSummary.join(', '), + color, + } + } + + static get defaultBadgeData() { + return { + label: 'symfony insight', + } + } + + static get category() { + return 'quality' + } + + static get route() { + return { + base: '', + // The SymfonyInsight service was previously branded as SensioLabs, and + // accordingly the badge path used to be /sensiolabs/i/projectUuid'. + // This is used to provide backward compatibility for the old path as well as + // supporting the new/current path. + format: '(?:sensiolabs/i|symfony/i/(grade|violations))/([^/]+)', + capture: ['metric', 'projectUuid'], + } + } + + static get examples() { + return [ + { + title: 'SymfonyInsight Grade', + pattern: 'symfony/i/grade/:projectUuid', + namedParams: { + projectUuid: '45afb680-d4e6-4e66-93ea-bcfa79eb8a87', + }, + staticPreview: this.renderGradeBadge({ + grade: 'bronze', + }), + keywords, + }, + { + title: 'SymfonyInsight Violations', + pattern: 'symfony/i/violations/:projectUuid', + namedParams: { + projectUuid: '45afb680-d4e6-4e66-93ea-bcfa79eb8a87', + }, + staticPreview: this.renderViolationsBadge({ + numViolations: 0, + }), + keywords, + }, + ] + } + + 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, + errorMessages: { + 401: 'not authorized to access project', + 404: 'project not found', + }, + parserOptions: { + attributeNamePrefix: '', + ignoreAttributes: false, + }, + }) + } + + transform({ data }) { + const lastAnalysis = data.project['last-analysis'] + let numViolations = 0 + let numCriticalViolations = 0 + let numMajorViolations = 0 + let numMinorViolations = 0 + let numInfoViolations = 0 + + const violationContainer = lastAnalysis.violations + if (violationContainer && violationContainer.violation) { + let violations = [] + // See above note on schema RE: https://github.com/NaturalIntelligence/fast-xml-parser/issues/68 + // This covers the scenario of multiple violations which are parsed as an array and single + // violations which is parsed as a single object. + if (Array.isArray(violationContainer.violation)) { + violations = violationContainer.violation + } else { + violations.push(violationContainer.violation) + } + numViolations = violations.length + violations.forEach(violation => { + if (violation.severity === 'critical') { + numCriticalViolations++ + } else if (violation.severity === 'major') { + numMajorViolations++ + } else if (violation.severity === 'minor') { + numMinorViolations++ + } else { + numInfoViolations++ + } + }) + } + + return { + status: lastAnalysis.status, + grade: lastAnalysis.grade, + numViolations, + numCriticalViolations, + numMajorViolations, + numMinorViolations, + numInfoViolations, + } + } + + async handle({ metric = 'grade', projectUuid }) { + const data = await this.fetch({ projectUuid }) + const { + status, + grade, + numViolations, + numCriticalViolations, + numMajorViolations, + numMinorViolations, + numInfoViolations, + } = this.transform({ data }) + + return this.constructor.render({ + metric, + status, + grade, + numViolations, + numCriticalViolations, + numMajorViolations, + numMinorViolations, + numInfoViolations, + }) + } +} diff --git a/services/symfony/symfony-insight.tester.js b/services/symfony/symfony-insight.tester.js new file mode 100644 index 0000000000000..fb63e087a9fa6 --- /dev/null +++ b/services/symfony/symfony-insight.tester.js @@ -0,0 +1,318 @@ +'use strict' + +const Joi = require('joi') +const { colorScheme } = require('../test-helpers') +const t = (module.exports = require('../create-service-tester')()) +const { withRegex } = require('../test-validators') + +const { + runningMockResponse, + platinumMockResponse, + goldMockResponse, + silverMockResponse, + bronzeMockResponse, + noMedalMockResponse, + mockSymfonyUser, + mockSymfonyToken, + mockSymfonyInsightCreds, + setSymfonyInsightCredsToFalsy, + restore, + realTokenExists, + prepLiveTest, + criticalViolation, + majorViolation, + minorViolation, + infoViolation, + multipleViolations, +} = require('./symfony-test-helpers') + +const sampleProjectUuid = '45afb680-d4e6-4e66-93ea-bcfa79eb8a87' + +function create(title, { withMockCreds = true } = { withMockCreds: true }) { + const result = t.create(title) + if (withMockCreds) { + result.before(mockSymfonyInsightCreds) + result.finally(restore) + } + return result +} + +create('live: valid project grade', { withMockCreds: false }) + .before(prepLiveTest) + .get(`/symfony/i/grade/${sampleProjectUuid}.json`) + .timeout(15000) + .interceptIf(!realTokenExists, nock => + nock('https://insight.symfony.com/api/projects') + .get(`/${sampleProjectUuid}`) + .reply(200, platinumMockResponse) + ) + .expectJSONTypes( + Joi.object().keys({ + name: 'grade', + value: Joi.equal( + 'platinum', + 'gold', + 'silver', + 'bronze', + 'no medal' + ).required(), + }) + ) + +create('live: valid project violations', { withMockCreds: false }) + .before(prepLiveTest) + .get(`/symfony/i/violations/${sampleProjectUuid}.json`) + .timeout(15000) + .interceptIf(!realTokenExists, nock => + nock('https://insight.symfony.com/api/projects') + .get(`/${sampleProjectUuid}`) + .reply(200, multipleViolations) + ) + .expectJSONTypes( + Joi.object().keys({ + name: 'violations', + value: 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/ + ), + }) + ) + +create('live: nonexistent project', { withMockCreds: false }) + .before(prepLiveTest) + .get('/symfony/i/grade/45afb680-d4e6-4e66-93ea-bcfa79eb8a88.json') + .interceptIf(!realTokenExists, nock => + nock('https://insight.symfony.com/api/projects') + .get('/45afb680-d4e6-4e66-93ea-bcfa79eb8a88') + .reply(404) + ) + .expectJSON({ + name: 'symfony insight', + value: 'project not found', + }) + +create('404 project not found grade') + .get(`/symfony/i/grade/${sampleProjectUuid}.json`) + .intercept(nock => + nock('https://insight.symfony.com/api/projects') + .get(`/${sampleProjectUuid}`) + .reply(404) + ) + .expectJSON({ + name: 'symfony insight', + value: 'project not found', + }) + +create('401 not authorized grade') + .get(`/symfony/i/grade/${sampleProjectUuid}.json`) + .intercept(nock => + nock('https://insight.symfony.com/api/projects') + .get(`/${sampleProjectUuid}`) + .reply(401) + ) + .expectJSON({ + name: 'symfony insight', + value: 'not authorized to access project', + }) + +create('pending project grade') + .get(`/symfony/i/grade/${sampleProjectUuid}.json?style=_shields_test`) + .intercept(nock => + nock('https://insight.symfony.com/api/projects') + .get(`/${sampleProjectUuid}`) + .reply(200, runningMockResponse) + ) + .expectJSON({ + name: 'grade', + value: 'pending', + colorB: colorScheme.lightgrey, + }) + +create('platinum grade') + .get(`/symfony/i/grade/${sampleProjectUuid}.json?style=_shields_test`) + .intercept(nock => + nock('https://insight.symfony.com/api/projects') + .get(`/${sampleProjectUuid}`) + .reply(200, platinumMockResponse) + ) + .expectJSON({ + name: 'grade', + value: 'platinum', + colorB: '#E5E4E2', + }) + +create('gold grade') + .get(`/symfony/i/grade/${sampleProjectUuid}.json?style=_shields_test`) + .intercept(nock => + nock('https://insight.symfony.com/api/projects') + .get(`/${sampleProjectUuid}`) + .reply(200, goldMockResponse) + ) + .expectJSON({ + name: 'grade', + value: 'gold', + colorB: '#EBC760', + }) + +create('silver grade') + .get(`/symfony/i/grade/${sampleProjectUuid}.json?style=_shields_test`) + .intercept(nock => + nock('https://insight.symfony.com/api/projects') + .get(`/${sampleProjectUuid}`) + .reply(200, silverMockResponse) + ) + .expectJSON({ + name: 'grade', + value: 'silver', + colorB: '#C0C0C0', + }) + +create('bronze grade') + .get(`/symfony/i/grade/${sampleProjectUuid}.json?style=_shields_test`) + .intercept(nock => + nock('https://insight.symfony.com/api/projects') + .get(`/${sampleProjectUuid}`) + .reply(200, bronzeMockResponse) + ) + .expectJSON({ + name: 'grade', + value: 'bronze', + colorB: '#C88F6A', + }) + +create('no medal grade') + .get(`/symfony/i/grade/${sampleProjectUuid}.json?style=_shields_test`) + .intercept(nock => + nock('https://insight.symfony.com/api/projects') + .get(`/${sampleProjectUuid}`) + .reply(200, noMedalMockResponse) + ) + .expectJSON({ + name: 'grade', + value: 'no medal', + colorB: colorScheme.red, + }) + +create('zero violations') + .get(`/symfony/i/violations/${sampleProjectUuid}.json?style=_shields_test`) + .intercept(nock => + nock('https://insight.symfony.com/api/projects') + .get(`/${sampleProjectUuid}`) + .reply(200, goldMockResponse) + ) + .expectJSON({ + name: 'violations', + value: '0', + colorB: colorScheme.brightgreen, + }) + +create('critical violations') + .get(`/symfony/i/violations/${sampleProjectUuid}.json?style=_shields_test`) + .intercept(nock => + nock('https://insight.symfony.com/api/projects') + .get(`/${sampleProjectUuid}`) + .reply(200, criticalViolation) + ) + .expectJSON({ + name: 'violations', + value: '1 critical', + colorB: colorScheme.red, + }) + +create('major violations') + .get(`/symfony/i/violations/${sampleProjectUuid}.json?style=_shields_test`) + .intercept(nock => + nock('https://insight.symfony.com/api/projects') + .get(`/${sampleProjectUuid}`) + .reply(200, majorViolation) + ) + .expectJSON({ + name: 'violations', + value: '1 major', + colorB: colorScheme.orange, + }) + +create('minor violations') + .get(`/symfony/i/violations/${sampleProjectUuid}.json?style=_shields_test`) + .intercept(nock => + nock('https://insight.symfony.com/api/projects') + .get(`/${sampleProjectUuid}`) + .basicAuth({ + user: mockSymfonyUser, + pass: mockSymfonyToken, + }) + .reply(200, minorViolation) + ) + .expectJSON({ + name: 'violations', + value: '1 minor', + colorB: colorScheme.yellow, + }) + +create('info violations') + .get(`/symfony/i/violations/${sampleProjectUuid}.json?style=_shields_test`) + .intercept(nock => + nock('https://insight.symfony.com/api/projects') + .get(`/${sampleProjectUuid}`) + .basicAuth({ + user: mockSymfonyUser, + pass: mockSymfonyToken, + }) + .reply(200, infoViolation) + ) + .expectJSON({ + name: 'violations', + value: '1 info', + colorB: colorScheme.yellowgreen, + }) + +create('multiple violations grade') + .get(`/symfony/i/violations/${sampleProjectUuid}.json?style=_shields_test`) + .intercept(nock => + nock('https://insight.symfony.com/api/projects') + .get(`/${sampleProjectUuid}`) + .basicAuth({ + user: mockSymfonyUser, + pass: mockSymfonyToken, + }) + .reply(200, multipleViolations) + ) + .expectJSON({ + name: 'violations', + value: '1 critical, 1 info', + colorB: colorScheme.red, + }) + +create('auth missing', { withMockCreds: false }) + .before(setSymfonyInsightCredsToFalsy) + .get(`/symfony/i/grade/${sampleProjectUuid}.json`) + .expectJSON({ + name: 'symfony insight', + value: 'required API tokens not found in config', + }) + +// These tests ensure that the legacy badge path (/sensiolabs/i/projectUuid) still works +create('legacy path: pending project grade') + .get(`/sensiolabs/i/${sampleProjectUuid}.json?style=_shields_test`) + .intercept(nock => + nock('https://insight.symfony.com/api/projects') + .get(`/${sampleProjectUuid}`) + .reply(200, runningMockResponse) + ) + .expectJSON({ + name: 'grade', + value: 'pending', + colorB: colorScheme.lightgrey, + }) + +create('legacy path: platinum grade') + .get(`/sensiolabs/i/${sampleProjectUuid}.json?style=_shields_test`) + .intercept(nock => + nock('https://insight.symfony.com/api/projects') + .get(`/${sampleProjectUuid}`) + .reply(200, platinumMockResponse) + ) + .expectJSON({ + name: 'grade', + value: 'platinum', + colorB: '#E5E4E2', + }) diff --git a/services/symfony/symfony-test-helpers.js b/services/symfony/symfony-test-helpers.js new file mode 100644 index 0000000000000..43d27dd267b88 --- /dev/null +++ b/services/symfony/symfony-test-helpers.js @@ -0,0 +1,134 @@ +'use strict' + +const sinon = require('sinon') +const serverSecrets = require('../../lib/server-secrets') + +function createMockResponse({ status = 'finished', grade, violations }) { + let response = ` + + + + ${grade ? `` : ''}` + if (violations) { + response = `${response}` + violations.forEach(v => { + response = `${response}` + }) + response = `${response}` + } + return `${response}` +} + +const runningMockResponse = createMockResponse({ + status: 'running', +}) +const platinumMockResponse = createMockResponse({ + grade: 'platinum', +}) +const goldMockResponse = createMockResponse({ + grade: 'gold', +}) +const silverMockResponse = createMockResponse({ + grade: 'silver', +}) +const bronzeMockResponse = createMockResponse({ + grade: 'bronze', +}) +const noMedalMockResponse = createMockResponse({ + grade: 'none', +}) +const criticalViolation = createMockResponse({ + violations: [ + { + severity: 'critical', + }, + ], +}) +const majorViolation = createMockResponse({ + violations: [ + { + severity: 'major', + }, + ], +}) +const minorViolation = createMockResponse({ + violations: [ + { + severity: 'minor', + }, + ], +}) +const infoViolation = createMockResponse({ + violations: [ + { + severity: 'info', + }, + ], +}) +const multipleViolations = createMockResponse({ + violations: [ + { + severity: 'info', + }, + { + severity: 'critical', + }, + ], +}) + +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 +} + +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) { + console.warn( + 'No token provided, this test will mock Symfony Insight API responses.' + ) + mockSymfonyInsightCreds() + } +} + +module.exports = { + runningMockResponse, + platinumMockResponse, + goldMockResponse, + silverMockResponse, + bronzeMockResponse, + noMedalMockResponse, + mockSymfonyUser, + mockSymfonyToken, + mockSymfonyInsightCreds, + setSymfonyInsightCredsToFalsy, + restore, + realTokenExists: originalUuid, + prepLiveTest, + criticalViolation, + majorViolation, + minorViolation, + infoViolation, + multipleViolations, +}