import path from 'path' import { fileURLToPath } from 'url' import { expect } from 'chai' import isSvg from 'is-svg' import config from 'config' import nock from 'nock' import sinon from 'sinon' import got from '../got-test-client.js' import Server from './server.js' import { createTestServer } from './in-process-server-test-helpers.js' describe('The server', function () { describe('running', function () { let server, baseUrl before('Start the server', async function () { // Fixes https://github.com/badges/shields/issues/2611 this.timeout(10000) server = await createTestServer({ public: { documentRoot: path.resolve( path.dirname(fileURLToPath(import.meta.url)), 'test-public' ), }, }) baseUrl = server.baseUrl await server.start() }) after('Shut down the server', async function () { if (server) { await server.stop() } server = undefined }) it('should allow strings for port', async function () { // fixes #4391 - This allows the app to be run using iisnode, which uses a named pipe for the port. const pipeServer = await createTestServer({ public: { bind: { port: '\\\\.\\pipe\\9c137306-7c4d-461e-b7cf-5213a3939ad6', }, }, }) expect(pipeServer).to.not.be.undefined }) it('should produce colorscheme badges', async function () { const { statusCode, body } = await got(`${baseUrl}:fruit-apple-green.svg`) expect(statusCode).to.equal(200) expect(body) .to.satisfy(isSvg) .and.to.include('fruit') .and.to.include('apple') }) it('should serve front-end with default maxAge', async function () { const { headers } = await got(`${baseUrl}/`) expect(headers['cache-control']).to.equal('max-age=300, s-maxage=300') }) it('should serve badges with custom maxAge', async function () { const { headers } = await got(`${baseUrl}badge/foo-bar-blue`) expect(headers['cache-control']).to.equal('max-age=86400, s-maxage=86400') }) it('should return cors header for the request', async function () { const { statusCode, headers } = await got( `${baseUrl}badge/foo-bar-blue.svg` ) expect(statusCode).to.equal(200) expect(headers['access-control-allow-origin']).to.equal('*') }) it('should redirect colorscheme PNG badges as configured', async function () { const { statusCode, headers } = await got( `${baseUrl}:fruit-apple-green.png`, { followRedirect: false, } ) expect(statusCode).to.equal(301) expect(headers.location).to.equal( 'http://raster.example.test/:fruit-apple-green.png' ) }) it('should redirect modern PNG badges as configured', async function () { const { statusCode, headers } = await got( `${baseUrl}badge/foo-bar-blue.png`, { followRedirect: false, } ) expect(statusCode).to.equal(301) expect(headers.location).to.equal( 'http://raster.example.test/badge/foo-bar-blue.png' ) }) it('should produce SVG badges with expected headers', async function () { const { statusCode, headers } = await got( `${baseUrl}:fruit-apple-green.svg` ) expect(statusCode).to.equal(200) expect(headers['content-type']).to.equal('image/svg+xml;charset=utf-8') expect(headers['content-length']).to.equal('1130') }) it('correctly calculates the content-length header for multi-byte unicode characters', async function () { const { headers } = await got(`${baseUrl}:fruit-apple🍏-green.json`) expect(headers['content-length']).to.equal('100') }) it('should produce JSON badges with expected headers', async function () { const { statusCode, body, headers } = await got( `${baseUrl}:fruit-apple-green.json` ) expect(statusCode).to.equal(200) expect(headers['content-type']).to.equal('application/json') expect(headers['access-control-allow-origin']).to.equal('*') expect(headers['content-length']).to.equal('92') expect(() => JSON.parse(body)).not.to.throw() }) it('should preserve label case', async function () { const { statusCode, body } = await got(`${baseUrl}:fRuiT-apple-green.svg`) expect(statusCode).to.equal(200) expect(body).to.satisfy(isSvg).and.to.include('fRuiT') }) // https://github.com/badges/shields/pull/1319 it('should not crash with a numeric logo', async function () { const { statusCode, body } = await got( `${baseUrl}:fruit-apple-green.svg?logo=1` ) expect(statusCode).to.equal(200) expect(body) .to.satisfy(isSvg) .and.to.include('fruit') .and.to.include('apple') }) it('should not crash with a numeric link', async function () { const { statusCode, body } = await got( `${baseUrl}:fruit-apple-green.svg?link=1` ) expect(statusCode).to.equal(200) expect(body) .to.satisfy(isSvg) .and.to.include('fruit') .and.to.include('apple') }) it('should not crash with a boolean link', async function () { const { statusCode, body } = await got( `${baseUrl}:fruit-apple-green.svg?link=true` ) expect(statusCode).to.equal(200) expect(body) .to.satisfy(isSvg) .and.to.include('fruit') .and.to.include('apple') }) it('should return the 404 badge for unknown badges', async function () { const { statusCode, body } = await got( `${baseUrl}this/is/not/a/badge.svg`, { throwHttpErrors: false, } ) expect(statusCode).to.equal(404) expect(body) .to.satisfy(isSvg) .and.to.include('404') .and.to.include('badge not found') }) it('should return the 404 badge page for rando links', async function () { const { statusCode, body } = await got( `${baseUrl}this/is/most/definitely/not/a/badge.js`, { throwHttpErrors: false, } ) expect(statusCode).to.equal(404) expect(body) .to.satisfy(isSvg) .and.to.include('404') .and.to.include('badge not found') }) it('should redirect the root as configured', async function () { const { statusCode, headers } = await got(baseUrl, { followRedirect: false, }) expect(statusCode).to.equal(302) // This value is set in `config/test.yml` expect(headers.location).to.equal('http://frontend.example.test') }) it('should return the 410 badge for obsolete formats', async function () { const { statusCode, body } = await got( `${baseUrl}badge/foo-bar-blue.jpg`, { throwHttpErrors: false, } ) // TODO It would be nice if this were 404 or 410. expect(statusCode).to.equal(200) expect(body) .to.satisfy(isSvg) .and.to.include('410') .and.to.include('jpg no longer available') }) }) context('`requireCloudflare` is enabled', function () { let server afterEach(async function () { if (server) { server.stop() } }) it('should reject requests from localhost with an empty 200 response', async function () { this.timeout(10000) server = await createTestServer({ public: { requireCloudflare: true } }) await server.start() const { statusCode, body } = await got( `${server.baseUrl}badge/foo-bar-blue.svg` ) expect(statusCode).to.be.equal(200) expect(body).to.equal('') }) }) describe('`requestTimeoutSeconds` setting', function () { let server beforeEach(async function () { this.timeout(10000) // configure server to time out requests that take >2 seconds server = await createTestServer({ public: { requestTimeoutSeconds: 2 } }) await server.start() // /fast returns a 200 OK after a 1 second delay server.camp.route(/^\/fast$/, (data, match, end, ask) => { setTimeout(() => { ask.res.statusCode = 200 ask.res.end() }, 1000) }) // /slow returns a 200 OK after a 3 second delay server.camp.route(/^\/slow$/, (data, match, end, ask) => { setTimeout(() => { ask.res.statusCode = 200 ask.res.end() }, 3000) }) }) afterEach(async function () { if (server) { server.stop() } server = undefined }) it('should time out slow requests', async function () { this.timeout(10000) const { statusCode, body } = await got(`${server.baseUrl}slow`, { throwHttpErrors: false, }) expect(statusCode).to.be.equal(408) expect(body).to.equal('Request Timeout') }) it('should not time out fast requests', async function () { this.timeout(10000) const { statusCode, body } = await got(`${server.baseUrl}fast`) expect(statusCode).to.be.equal(200) expect(body).to.equal('') }) }) describe('configuration', function () { let server afterEach(async function () { if (server) { server.stop() } }) it('should allow to enable prometheus metrics', async function () { // Fixes https://github.com/badges/shields/issues/2611 this.timeout(10000) server = await createTestServer({ public: { metrics: { prometheus: { enabled: true, endpointEnabled: true } }, }, }) await server.start() const { statusCode } = await got(`${server.baseUrl}metrics`) expect(statusCode).to.be.equal(200) }) it('should allow to disable prometheus metrics', async function () { // Fixes https://github.com/badges/shields/issues/2611 this.timeout(10000) server = await createTestServer({ public: { metrics: { prometheus: { enabled: true, endpointEnabled: false } }, }, }) await server.start() const { statusCode } = await got(`${server.baseUrl}metrics`, { throwHttpErrors: false, }) expect(statusCode).to.be.equal(404) }) }) describe('configuration validation', function () { describe('influx', function () { let customConfig beforeEach(function () { customConfig = config.util.toObject() customConfig.public.metrics.influx = { enabled: true, url: 'http://localhost:8081/telegraf', timeoutMilliseconds: 1000, intervalSeconds: 2, instanceIdFrom: 'random', instanceIdEnvVarName: 'INSTANCE_ID', hostnameAliases: { 'metrics-hostname': 'metrics-hostname-alias' }, envLabel: 'test-env', } customConfig.private = { influx_username: 'telegraf', influx_password: 'telegrafpass', } }) it('should not require influx configuration', function () { delete customConfig.public.metrics.influx expect(() => new Server(config.util.toObject())).to.not.throw() }) it('should require url when influx configuration is enabled', function () { delete customConfig.public.metrics.influx.url expect(() => new Server(customConfig)).to.throw( '"metrics.influx.url" is required' ) }) it('should not require url when influx configuration is disabled', function () { customConfig.public.metrics.influx.enabled = false delete customConfig.public.metrics.influx.url expect(() => new Server(customConfig)).to.not.throw() }) it('should require timeoutMilliseconds when influx configuration is enabled', function () { delete customConfig.public.metrics.influx.timeoutMilliseconds expect(() => new Server(customConfig)).to.throw( '"metrics.influx.timeoutMilliseconds" is required' ) }) it('should require intervalSeconds when influx configuration is enabled', function () { delete customConfig.public.metrics.influx.intervalSeconds expect(() => new Server(customConfig)).to.throw( '"metrics.influx.intervalSeconds" is required' ) }) it('should require instanceIdFrom when influx configuration is enabled', function () { delete customConfig.public.metrics.influx.instanceIdFrom expect(() => new Server(customConfig)).to.throw( '"metrics.influx.instanceIdFrom" is required' ) }) it('should require instanceIdEnvVarName when instanceIdFrom is env-var', function () { customConfig.public.metrics.influx.instanceIdFrom = 'env-var' delete customConfig.public.metrics.influx.instanceIdEnvVarName expect(() => new Server(customConfig)).to.throw( '"metrics.influx.instanceIdEnvVarName" is required' ) }) it('should allow instanceIdFrom = hostname', function () { customConfig.public.metrics.influx.instanceIdFrom = 'hostname' expect(() => new Server(customConfig)).to.not.throw() }) it('should allow instanceIdFrom = env-var', function () { customConfig.public.metrics.influx.instanceIdFrom = 'env-var' expect(() => new Server(customConfig)).to.not.throw() }) it('should allow instanceIdFrom = random', function () { customConfig.public.metrics.influx.instanceIdFrom = 'random' expect(() => new Server(customConfig)).to.not.throw() }) it('should require envLabel when influx configuration is enabled', function () { delete customConfig.public.metrics.influx.envLabel expect(() => new Server(customConfig)).to.throw( '"metrics.influx.envLabel" is required' ) }) it('should not require hostnameAliases', function () { delete customConfig.public.metrics.influx.hostnameAliases expect(() => new Server(customConfig)).to.not.throw() }) it('should allow empty hostnameAliases', function () { customConfig.public.metrics.influx.hostnameAliases = {} expect(() => new Server(customConfig)).to.not.throw() }) it('should require username when influx configuration is enabled', function () { delete customConfig.private.influx_username expect(() => new Server(customConfig)).to.throw( 'Private configuration is invalid. Check these paths: influx_username' ) }) it('should require password when influx configuration is enabled', function () { delete customConfig.private.influx_password expect(() => new Server(customConfig)).to.throw( 'Private configuration is invalid. Check these paths: influx_password' ) }) it('should allow other private keys', function () { customConfig.private.gh_token = 'my-token' expect(() => new Server(customConfig)).to.not.throw() }) }) }) describe('running with metrics enabled', function () { let server, baseUrl, scope, clock const metricsPushIntervalSeconds = 1 before('Start the server', async function () { // Fixes https://github.com/badges/shields/issues/2611 this.timeout(10000) process.env.INSTANCE_ID = 'test-instance' server = await createTestServer({ public: { metrics: { prometheus: { enabled: true }, influx: { enabled: true, url: 'http://localhost:1112/metrics', instanceIdFrom: 'env-var', instanceIdEnvVarName: 'INSTANCE_ID', envLabel: 'localhost-env', intervalSeconds: metricsPushIntervalSeconds, }, }, }, private: { influx_username: 'influx-username', influx_password: 'influx-password', }, }) clock = sinon.useFakeTimers() baseUrl = server.baseUrl await server.start() }) after('Shut down the server', async function () { if (server) { await server.stop() } server = undefined nock.cleanAll() delete process.env.INSTANCE_ID clock.restore() }) it('should push custom metrics', async function () { scope = nock('http://localhost:1112', { reqheaders: { 'Content-Type': 'application/x-www-form-urlencoded', }, }) .post( '/metrics', /prometheus,application=shields,category=static,env=localhost-env,family=static-badge,instance=test-instance,service=static_badge service_requests_total=1\n/ ) .basicAuth({ user: 'influx-username', pass: 'influx-password' }) .reply(200) await got(`${baseUrl}badge/fruit-apple-green.svg`) await clock.tickAsync(1000 * metricsPushIntervalSeconds + 500) expect(scope.isDone()).to.be.equal( true, `pending mocks: ${scope.pendingMocks()}` ) }) }) })