Files
shields/services/base.spec.js
2019-01-09 16:32:28 -05:00

707 lines
21 KiB
JavaScript

'use strict'
const Joi = require('joi')
const { expect } = require('chai')
const { test, given, forCases } = require('sazerac')
const sinon = require('sinon')
const trace = require('./trace')
const {
NotFound,
Inaccessible,
InvalidResponse,
InvalidParameter,
Deprecated,
} = require('./errors')
const BaseService = require('./base')
require('../lib/register-chai-plugins.spec')
class DummyService extends BaseService {
static render({ namedParamA, queryParamA }) {
return {
message: `Hello namedParamA: ${namedParamA} with queryParamA: ${queryParamA}`,
}
}
async handle({ namedParamA }, { queryParamA }) {
return this.constructor.render({ namedParamA, queryParamA })
}
static get category() {
return 'other'
}
static get defaultBadgeData() {
return { label: 'cat' }
}
static get examples() {
return [
{ previewUrl: 'World' },
{ previewUrl: 'World', queryParams: { queryParamA: '!!!' } },
{
pattern: ':world',
namedParams: { world: 'World' },
staticPreview: this.render({ namedParamA: 'foo', queryParamA: 'bar' }),
keywords: ['hello'],
},
{
namedParams: { namedParamA: 'World' },
staticPreview: this.render({ namedParamA: 'foo', queryParamA: 'bar' }),
keywords: ['hello'],
},
{
pattern: ':world',
namedParams: { world: 'World' },
queryParams: { queryParamA: '!!!' },
staticPreview: this.render({ namedParamA: 'foo', queryParamA: 'bar' }),
keywords: ['hello'],
},
]
}
static get route() {
return {
base: 'foo',
pattern: ':namedParamA',
queryParams: ['queryParamA'],
}
}
}
describe('BaseService', function() {
const defaultConfig = { handleInternalErrors: false }
describe('URL pattern matching', function() {
context('A `pattern` with a named param is declared', function() {
const regexExec = str => DummyService._regex.exec(str)
const getNamedParamA = str => {
const [, namedParamA] = regexExec(str)
return namedParamA
}
const namedParams = str => {
const match = regexExec(str)
return DummyService._namedParamsForMatch(match)
}
test(regexExec, () => {
forCases([
given('/foo/bar.bar.bar.zip'),
given('/foo/bar/bar.svg'),
// This is a valid example with the wrong extension separator, to
// test that we only accept a `.`.
given('/foo/bar.bar.bar_svg'),
]).expect(null)
})
test(getNamedParamA, () => {
forCases([
given('/foo/bar.bar.bar.svg'),
given('/foo/bar.bar.bar.png'),
given('/foo/bar.bar.bar.gif'),
given('/foo/bar.bar.bar.jpg'),
given('/foo/bar.bar.bar.json'),
]).expect('bar.bar.bar')
})
test(namedParams, () => {
forCases([
given('/foo/bar.bar.bar.svg'),
given('/foo/bar.bar.bar.png'),
given('/foo/bar.bar.bar.gif'),
given('/foo/bar.bar.bar.jpg'),
given('/foo/bar.bar.bar.json'),
]).expect({ namedParamA: 'bar.bar.bar' })
})
})
context('A `format` with a named param is declared', function() {
class ServiceWithFormat extends BaseService {
static get route() {
return {
base: 'foo',
format: '([^/]+)',
capture: ['namedParamA'],
}
}
}
const regexExec = str => ServiceWithFormat._regex.exec(str)
const getNamedParamA = str => {
const [, namedParamA] = regexExec(str)
return namedParamA
}
const namedParams = str => {
const match = regexExec(str)
return ServiceWithFormat._namedParamsForMatch(match)
}
test(regexExec, () => {
forCases([
given('/foo/bar.bar.bar.zip'),
given('/foo/bar/bar.svg'),
// This is a valid example with the wrong extension separator, to
// test that we only accept a `.`.
given('/foo/bar.bar.bar_svg'),
]).expect(null)
})
test(getNamedParamA, () => {
forCases([
given('/foo/bar.bar.bar.svg'),
given('/foo/bar.bar.bar.png'),
given('/foo/bar.bar.bar.gif'),
given('/foo/bar.bar.bar.jpg'),
given('/foo/bar.bar.bar.json'),
]).expect('bar.bar.bar')
})
test(namedParams, () => {
forCases([
given('/foo/bar.bar.bar.svg'),
given('/foo/bar.bar.bar.png'),
given('/foo/bar.bar.bar.gif'),
given('/foo/bar.bar.bar.jpg'),
given('/foo/bar.bar.bar.json'),
]).expect({ namedParamA: 'bar.bar.bar' })
})
})
context('No named params are declared', function() {
class ServiceWithZeroNamedParams extends BaseService {
static get route() {
return {
base: 'foo',
format: '(?:[^/]+)',
}
}
}
const namedParams = str => {
const match = ServiceWithZeroNamedParams._regex.exec(str)
return ServiceWithZeroNamedParams._namedParamsForMatch(match)
}
test(namedParams, () => {
forCases([
given('/foo/bar.bar.bar.svg'),
given('/foo/bar.bar.bar.png'),
given('/foo/bar.bar.bar.gif'),
given('/foo/bar.bar.bar.jpg'),
given('/foo/bar.bar.bar.json'),
]).expect({})
})
})
})
it('Invokes the handler as expected', async function() {
expect(
await DummyService.invoke(
{},
defaultConfig,
{ namedParamA: 'bar.bar.bar' },
{ queryParamA: '!' }
)
).to.deep.equal({
message: 'Hello namedParamA: bar.bar.bar with queryParamA: !',
})
})
describe('Logging', function() {
let sandbox
beforeEach(function() {
sandbox = sinon.createSandbox()
})
afterEach(function() {
sandbox.restore()
})
beforeEach(function() {
sandbox.stub(trace, 'logTrace')
})
it('Invokes the logger as expected', async function() {
await DummyService.invoke(
{},
defaultConfig,
{ namedParamA: 'bar.bar.bar' },
{ queryParamA: '!' }
)
expect(trace.logTrace).to.be.calledWithMatch(
'inbound',
sinon.match.string,
'Service class',
'DummyService'
)
expect(trace.logTrace).to.be.calledWith(
'inbound',
sinon.match.string,
'Named params',
{ namedParamA: 'bar.bar.bar' }
)
expect(trace.logTrace).to.be.calledWith(
'inbound',
sinon.match.string,
'Query params',
{ queryParamA: '!' }
)
})
})
describe('Error handling', function() {
it('Handles internal errors', async function() {
class ThrowingService extends DummyService {
async handle() {
throw Error("I've made a huge mistake")
}
}
expect(
await ThrowingService.invoke(
{},
{ handleInternalErrors: true },
{ namedParamA: 'bar.bar.bar' }
)
).to.deep.equal({
isError: true,
color: 'lightgray',
label: 'shields',
message: 'internal error',
})
})
describe('Handles known subtypes of ShieldsInternalError', function() {
it('handles NotFound errors', async function() {
class ThrowingService extends DummyService {
async handle() {
throw new NotFound()
}
}
expect(
await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' })
).to.deep.equal({
isError: true,
color: 'red',
message: 'not found',
})
})
it('handles Inaccessible errors', async function() {
class ThrowingService extends DummyService {
async handle() {
throw new Inaccessible()
}
}
expect(
await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' })
).to.deep.equal({
isError: true,
color: 'lightgray',
message: 'inaccessible',
})
})
it('handles InvalidResponse errors', async function() {
class ThrowingService extends DummyService {
async handle() {
throw new InvalidResponse()
}
}
expect(
await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' })
).to.deep.equal({
isError: true,
color: 'lightgray',
message: 'invalid',
})
})
it('handles Deprecated', async function() {
class ThrowingService extends DummyService {
async handle() {
throw new Deprecated()
}
}
expect(
await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' })
).to.deep.equal({
isError: true,
color: 'lightgray',
message: 'no longer available',
})
})
it('handles InvalidParameter errors', async function() {
class ThrowingService extends DummyService {
async handle() {
throw new InvalidParameter()
}
}
expect(
await ThrowingService.invoke({}, {}, { namedParamA: 'bar.bar.bar' })
).to.deep.equal({
isError: true,
color: 'red',
message: 'invalid parameter',
})
})
})
})
describe('_makeBadgeData', function() {
describe('Overrides', function() {
it('overrides the label', function() {
const badgeData = DummyService._makeBadgeData(
{ label: 'purr count' },
{ label: 'purrs' }
)
expect(badgeData.text).to.deep.equal(['purr count', 'n/a'])
})
it('overrides the colorA', function() {
const badgeData = DummyService._makeBadgeData(
{ colorA: '42f483' },
{ color: 'green' }
)
expect(badgeData.colorA).to.equal('#42f483')
})
it('overrides the color', function() {
const badgeData = DummyService._makeBadgeData(
{ colorB: '10ADED' },
{ color: 'red' }
)
expect(badgeData.colorB).to.equal('#10ADED')
})
it('does not override the color in case of an error', function() {
const badgeData = DummyService._makeBadgeData(
{ colorB: '10ADED' },
{ isError: true, color: 'lightgray' }
)
expect(badgeData.colorB).to.be.undefined
expect(badgeData.colorscheme).to.equal('lightgray')
})
it('overrides the logo', function() {
const expLogo =
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgdmlld0JveD0iMTIgMTIgNDAgNDAiPgo8cGF0aCBmaWxsPSIjMzMzMzMzIiBkPSJNMzIsMTMuNGMtMTAuNSwwLTE5LDguNS0xOSwxOWMwLDguNCw1LjUsMTUuNSwxMywxOGMxLDAuMiwxLjMtMC40LDEuMy0wLjljMC0wLjUsMC0xLjcsMC0zLjIgYy01LjMsMS4xLTYuNC0yLjYtNi40LTIuNkMyMCw0MS42LDE4LjgsNDEsMTguOCw0MWMtMS43LTEuMiwwLjEtMS4xLDAuMS0xLjFjMS45LDAuMSwyLjksMiwyLjksMmMxLjcsMi45LDQuNSwyLjEsNS41LDEuNiBjMC4yLTEuMiwwLjctMi4xLDEuMi0yLjZjLTQuMi0wLjUtOC43LTIuMS04LjctOS40YzAtMi4xLDAuNy0zLjcsMi01LjFjLTAuMi0wLjUtMC44LTIuNCwwLjItNWMwLDAsMS42LTAuNSw1LjIsMiBjMS41LTAuNCwzLjEtMC43LDQuOC0wLjdjMS42LDAsMy4zLDAuMiw0LjcsMC43YzMuNi0yLjQsNS4yLTIsNS4yLTJjMSwyLjYsMC40LDQuNiwwLjIsNWMxLjIsMS4zLDIsMywyLDUuMWMwLDcuMy00LjUsOC45LTguNyw5LjQgYzAuNywwLjYsMS4zLDEuNywxLjMsMy41YzAsMi42LDAsNC42LDAsNS4yYzAsMC41LDAuNCwxLjEsMS4zLDAuOWM3LjUtMi42LDEzLTkuNywxMy0xOC4xQzUxLDIxLjksNDIuNSwxMy40LDMyLDEzLjR6Ii8+Cjwvc3ZnPgo='
const badgeData = DummyService._makeBadgeData(
{ logo: 'github', style: 'social' },
{}
)
expect(badgeData.logo).to.equal(expLogo)
})
it('overrides the logo with color', function() {
const expLogo =
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgdmlld0JveD0iMTIgMTIgNDAgNDAiPgo8cGF0aCBmaWxsPSIjMDA3ZWM2IiBkPSJNMzIsMTMuNGMtMTAuNSwwLTE5LDguNS0xOSwxOWMwLDguNCw1LjUsMTUuNSwxMywxOGMxLDAuMiwxLjMtMC40LDEuMy0wLjljMC0wLjUsMC0xLjcsMC0zLjIgYy01LjMsMS4xLTYuNC0yLjYtNi40LTIuNkMyMCw0MS42LDE4LjgsNDEsMTguOCw0MWMtMS43LTEuMiwwLjEtMS4xLDAuMS0xLjFjMS45LDAuMSwyLjksMiwyLjksMmMxLjcsMi45LDQuNSwyLjEsNS41LDEuNiBjMC4yLTEuMiwwLjctMi4xLDEuMi0yLjZjLTQuMi0wLjUtOC43LTIuMS04LjctOS40YzAtMi4xLDAuNy0zLjcsMi01LjFjLTAuMi0wLjUtMC44LTIuNCwwLjItNWMwLDAsMS42LTAuNSw1LjIsMiBjMS41LTAuNCwzLjEtMC43LDQuOC0wLjdjMS42LDAsMy4zLDAuMiw0LjcsMC43YzMuNi0yLjQsNS4yLTIsNS4yLTJjMSwyLjYsMC40LDQuNiwwLjIsNWMxLjIsMS4zLDIsMywyLDUuMWMwLDcuMy00LjUsOC45LTguNyw5LjQgYzAuNywwLjYsMS4zLDEuNywxLjMsMy41YzAsMi42LDAsNC42LDAsNS4yYzAsMC41LDAuNCwxLjEsMS4zLDAuOWM3LjUtMi42LDEzLTkuNywxMy0xOC4xQzUxLDIxLjksNDIuNSwxMy40LDMyLDEzLjR6Ii8+Cjwvc3ZnPgo='
const badgeData = DummyService._makeBadgeData(
{ logo: 'github', logoColor: 'blue' },
{}
)
expect(badgeData.logo).to.equal(expLogo)
})
it('overrides the logoWidth', function() {
const badgeData = DummyService._makeBadgeData({ logoWidth: 20 }, {})
expect(badgeData.logoWidth).to.equal(20)
})
it('overrides the links', function() {
const badgeData = DummyService._makeBadgeData(
{ link: 'https://circleci.com/gh/badges/daily-tests' },
{
link:
'https://circleci.com/workflow-run/184ef3de-4836-4805-a2e4-0ceba099f92d',
}
)
expect(badgeData.links).to.deep.equal([
'https://circleci.com/gh/badges/daily-tests',
])
})
it('overrides the template', function() {
const badgeData = DummyService._makeBadgeData({ style: 'pill' }, {})
expect(badgeData.template).to.equal('pill')
})
})
describe('Service data', function() {
it('applies the service message', function() {
const badgeData = DummyService._makeBadgeData({}, { message: '10k' })
expect(badgeData.text).to.deep.equal(['cat', '10k'])
})
it('preserves an empty label', function() {
const badgeData = DummyService._makeBadgeData(
{},
{ label: '', message: '10k' }
)
expect(badgeData.text).to.deep.equal(['', '10k'])
})
it('applies a numeric service message', function() {
// While a number of badges use this, in the long run we may want
// `render()` to always return a string.
const badgeData = DummyService._makeBadgeData({}, { message: 10 })
expect(badgeData.text).to.deep.equal(['cat', 10])
})
it('applies the service color', function() {
const badgeData = DummyService._makeBadgeData({}, { color: 'red' })
expect(badgeData.colorscheme).to.equal('red')
})
})
describe('Defaults', function() {
it('uses the default label', function() {
const badgeData = DummyService._makeBadgeData({}, {})
expect(badgeData.text).to.deep.equal(['cat', 'n/a'])
})
it('uses the default color', function() {
const badgeData = DummyService._makeBadgeData({}, {})
expect(badgeData.colorscheme).to.equal('lightgrey')
})
})
})
describe('ScoutCamp integration', function() {
const expectedRouteRegex = /^\/foo\/([^/]+?)\.(svg|png|gif|jpg|json)$/
let mockCamp
let mockHandleRequest
beforeEach(function() {
mockCamp = {
route: sinon.spy(),
}
mockHandleRequest = sinon.spy()
DummyService.register(
{ camp: mockCamp, handleRequest: mockHandleRequest },
defaultConfig
)
})
it('registers the service', function() {
expect(mockCamp.route).to.have.been.calledOnce
expect(mockCamp.route).to.have.been.calledWith(expectedRouteRegex)
})
it('handles the request', async function() {
expect(mockHandleRequest).to.have.been.calledOnce
const { handler: requestHandler } = mockHandleRequest.getCall(0).args[1]
const mockSendBadge = sinon.spy()
const mockRequest = {
asPromise: sinon.spy(),
}
const queryParams = { queryParamA: '?' }
const match = '/foo/bar.svg'.match(expectedRouteRegex)
await requestHandler(queryParams, match, mockSendBadge, mockRequest)
const expectedFormat = 'svg'
expect(mockSendBadge).to.have.been.calledOnce
expect(mockSendBadge).to.have.been.calledWith(expectedFormat, {
text: ['cat', 'Hello namedParamA: bar with queryParamA: ?'],
colorscheme: 'lightgrey',
template: undefined,
logo: undefined,
logoWidth: NaN,
links: [],
colorA: undefined,
})
})
})
describe('getDefinition', function() {
it('returns the expected result', function() {
const {
category,
name,
isDeprecated,
route,
examples,
} = DummyService.getDefinition()
expect({
category,
name,
isDeprecated,
route,
}).to.deep.equal({
category: 'other',
name: 'DummyService',
isDeprecated: false,
route: {
pattern: '/foo/:namedParamA',
queryParams: [],
},
})
const [first, second, third, fourth, fifth] = examples
expect(first).to.deep.equal({
title: 'DummyService',
example: {
path: '/foo/World',
queryParams: {},
},
preview: {
path: '/foo/World',
queryParams: {},
},
keywords: [],
documentation: undefined,
})
expect(second).to.deep.equal({
title: 'DummyService',
example: {
path: '/foo/World',
queryParams: { queryParamA: '!!!' },
},
preview: {
path: '/foo/World',
queryParams: { queryParamA: '!!!' },
},
keywords: [],
documentation: undefined,
})
expect(third).to.deep.equal({
title: 'DummyService',
example: {
pattern: '/foo/:world',
namedParams: { world: 'World' },
queryParams: {},
},
preview: {
label: 'cat',
message: 'Hello namedParamA: foo with queryParamA: bar',
color: 'lightgrey',
},
keywords: ['hello'],
documentation: undefined,
})
expect(fourth).to.deep.equal({
title: 'DummyService',
example: {
pattern: '/foo/:namedParamA',
namedParams: { namedParamA: 'World' },
queryParams: {},
},
preview: {
label: 'cat',
message: 'Hello namedParamA: foo with queryParamA: bar',
color: 'lightgrey',
},
keywords: ['hello'],
documentation: undefined,
})
expect(fifth).to.deep.equal({
title: 'DummyService',
example: {
pattern: '/foo/:world',
namedParams: { world: 'World' },
queryParams: { queryParamA: '!!!' },
},
preview: {
color: 'lightgrey',
label: 'cat',
message: 'Hello namedParamA: foo with queryParamA: bar',
},
keywords: ['hello'],
documentation: undefined,
})
})
})
describe('validate', function() {
const dummySchema = Joi.object({
requiredString: Joi.string().required(),
}).required()
it('throws error for invalid responses', async function() {
try {
DummyService._validate(
{ requiredString: ['this', "shouldn't", 'work'] },
dummySchema
)
expect.fail('Expected to throw')
} catch (e) {
expect(e).to.be.an.instanceof(InvalidResponse)
}
})
it('throws error for invalid query params', async function() {
try {
DummyService._validateQueryParams(
{ requiredString: ['this', "shouldn't", 'work'] },
dummySchema
)
expect.fail('Expected to throw')
} catch (e) {
expect(e).to.be.an.instanceof(InvalidParameter)
}
})
})
describe('request', function() {
let sandbox
beforeEach(function() {
sandbox = sinon.createSandbox()
})
afterEach(function() {
sandbox.restore()
})
beforeEach(function() {
sandbox.stub(trace, 'logTrace')
})
it('logs appropriate information', async function() {
const sendAndCacheRequest = async () => ({
buffer: '',
res: { statusCode: 200 },
})
const serviceInstance = new DummyService(
{ sendAndCacheRequest },
defaultConfig
)
const url = 'some-url'
const options = { headers: { Cookie: 'some-cookie' } }
await serviceInstance._request({ url, options })
expect(trace.logTrace).to.be.calledWithMatch(
'fetch',
sinon.match.string,
'Request',
url,
'\n',
options
)
expect(trace.logTrace).to.be.calledWithMatch(
'fetch',
sinon.match.string,
'Response status code',
200
)
})
it('handles errors', async function() {
const sendAndCacheRequest = async () => ({
buffer: '',
res: { statusCode: 404 },
})
const serviceInstance = new DummyService(
{ sendAndCacheRequest },
defaultConfig
)
try {
await serviceInstance._request({})
expect.fail('Expected to throw')
} catch (e) {
expect(e).to.be.an.instanceof(NotFound)
expect(e.message).to.equal('Not Found')
expect(e.prettyMessage).to.equal('not found')
}
})
})
})