Repository: badges/shields (js)

Issue:
NuGet badges for multiple packages
Some projects/repos, e.g. https://github.com/scriptcs/scriptcs, produce a number of NuGet packages, e.g. https://www.nuget.org/profiles/scriptcs/.

It would be nice to be have NuGet badges which took all packages into account, certainly for download count.

Version would also work if the versions of all the packages are the same. If they differ, perhaps the badge could display the latest version, a version range, or just '4 packages'.

I think the name of each package would have to be specified in the URL, e.g. `http://img.shields.io/nuget/multi/dt/shields.svg?ScriptCs.Contracts&ScriptCs.Core&ScriptCs.Engine.Roslyn&ScriptCs.Hosting`

Specifying the packages using a filter is difficult since the only options are search, author and tags. The only one under definitive control of the publisher is author, but even that will only be narrowly useful since most projects don't create an author specifically for their package publishing. Even if they did, it would be fairly common that they would publish packages from many repos which means the badge wouldn't be useful for puttting on a repo specific readme. Still, that could also provided as an option, e.g. `http://img.shields.io/nuget/profile/dt/scriptcs.svg`.

I guess another badge could give package count, e.g. `http://img.shields.io/nuget/multi/pc/shields.svg?ScriptCs.Contracts&ScriptCs.Core&ScriptCs.Engine.Roslyn&ScriptCs.Hosting` and `http://img.shields.io/nuget/profile/pc/scriptcs.svg`.


Pull request description:
[Endpoint] badge
This reimplements the idea @bkdotcom came up with in #1519, and took a stab at in #1525. It’s a really powerful way to add all sorts of custom badges, particularly considering [tools like RunKit endpoints and Jupyter Kernel Gateway](https://github.com/badges/shields/issues/2259#issuecomment-444186589), not to mention all the other ways cloud functions can be deployed these days.

I gave this a name which did not have JSON in it, so as not to confuse it with the dynamic JSON badges.

There are a few things that need doing before this can be shipped, including supporting several of the options I included in the schema.

- [x] labelColor (#2740)
- [x] isError (#2693)
- [ ] ~~link (I honestly don't know much about our link support; should we have leftLink and rightLink?)~~ (Removed for this iteration)
- [x] namedLogo, logoSvg (#2796)
    - [x] test of logoSvg
- [x] logoColor, logoWidth, logoPosition (#2796)
    - [x] test of logoWidth, ~~logoPosition~~
- [x] style
- [x] cacheSeconds (#2755)
    - [x] Service test for cacheSeconds

Alternatively, they could be removed from the schema and added later on.

Curious for feedback! And thought I would open this up for comments before I got too far along.

Ref #1534 #2259. Closes #204 #358 #742 #999 #1519 #1752 #2625.

Patch:
diff --git a/core/base-service/validate.js b/core/base-service/validate.js
index 38adcebfdd..97f7c7a03e 100644
--- a/core/base-service/validate.js
+++ b/core/base-service/validate.js
@@ -11,6 +11,7 @@ function validate(
     includeKeys = false,
     traceErrorMessage = 'Data did not match schema',
     traceSuccessMessage = 'Data after validation',
+    allowAndStripUnknownKeys = true,
   },
   data,
   schema
@@ -18,10 +19,13 @@ function validate(
   if (!schema || !schema.isJoi) {
     throw Error('A Joi schema is required')
   }
-  const { error, value } = Joi.validate(data, schema, {
-    allowUnknown: true,
-    stripUnknown: true,
-  })
+  const options = allowAndStripUnknownKeys
+    ? {
+        allowUnknown: true,
+        stripUnknown: true,
+      }
+    : undefined
+  const { error, value } = Joi.validate(data, schema, options)
   if (error) {
     trace.logTrace(
       'validate',
diff --git a/core/base-service/validate.spec.js b/core/base-service/validate.spec.js
index 3a9afd8162..152a6423df 100644
--- a/core/base-service/validate.spec.js
+++ b/core/base-service/validate.spec.js
@@ -105,4 +105,14 @@ describe('validate', function() {
       })
     })
   })
+
+  it('allowAndStripUnknownKeys', function() {
+    expect(() =>
+      validate(
+        { ...options, allowAndStripUnknownKeys: false },
+        { requiredString: 'bar', extra: 'nonsense' },
+        schema
+      )
+    ).to.throw(InvalidParameter, '"extra" is not allowed')
+  })
 })
diff --git a/frontend/components/endpoint-page.js b/frontend/components/endpoint-page.js
new file mode 100644
index 0000000000..c423676f51
--- /dev/null
+++ b/frontend/components/endpoint-page.js
@@ -0,0 +1,217 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import styled from 'styled-components'
+import { staticBadgeUrl } from '../lib/badge-url'
+import { baseUrl } from '../constants'
+import Meta from './meta'
+import Header from './header'
+import Footer from './footer'
+import { H3, Badge } from './common'
+import { Snippet } from './snippet'
+
+const Explanation = styled.div`
+  max-width: 800px;
+  display: block;
+`
+
+const JsonExampleBlock = styled.code`
+  display: inline-block;
+
+  text-align: left;
+  line-height: 1.2em;
+  padding: 16px 18px;
+
+  border-radius: 4px;
+  background: #eef;
+
+  font-family: Lekton;
+  font-size: ${({ fontSize }) => fontSize};
+
+  white-space: pre;
+`
+
+const JsonExample = ({ data }) => (
+  <JsonExampleBlock>{JSON.stringify(data, undefined, 2)}</JsonExampleBlock>
+)
+JsonExample.propTypes = {
+  data: PropTypes.string.isRequired,
+}
+
+const Schema = styled.dl`
+  display: inline-block;
+  max-width: 800px;
+
+  margin: 0;
+  padding: 10px;
+  text-align: left;
+
+  background: #efefef;
+
+  clear: both;
+  overflow: hidden;
+
+  dt,
+  dd {
+    padding: 0 1%;
+    margin-top: 8px;
+    margin-bottom: 8px;
+    float: left;
+  }
+
+  dt {
+    width: 100px;
+    clear: both;
+  }
+
+  dd {
+    margin-left: 20px;
+    width: 75%;
+  }
+
+  @media (max-width: 600px) {
+    .data_table {
+      text-align: center;
+    }
+  }
+`
+
+const EndpointPage = () => (
+  <div>
+    <Meta />
+    <Header />
+    <H3 id="static-badge">Endpoint (Beta)</H3>
+    <Snippet snippet={`${baseUrl}/badge/endpoint.svg?url=...&style=...`} />
+    <p>Endpoint response:</p>
+    <JsonExample
+      data={{
+        schemaVersion: 1,
+        label: 'hello',
+        message: 'sweet world',
+        color: 'orange',
+      }}
+    />
+    <p>Shields response:</p>
+    <Badge
+      src={staticBadgeUrl(baseUrl, 'hello', 'sweet world', 'orange')}
+      alt="hello | sweet world"
+    />
+    <Explanation>
+      <p>
+        Developers rely on Shields for visual consistency and powerful
+        customization options. As a service provider or data provider, you can
+        use the endpoint badge to provide content while giving users the full
+        power of Shields' badge customization.
+      </p>
+      <p>
+        Using the endpoint badge, you can provide content for a badge through a
+        JSON endpoint. The content can be prerendered, or generated on the fly.
+        To strike a balance between responsiveness and bandwith utilization on
+        one hand, and freshness on the other, cache behavior is configurable,
+        subject to the Shields minimum. The endpoint URL is provided to Shields
+        through the query string. Shields fetches it and formats the badge.
+      </p>
+      <p>
+        The endpoint badge is a better alternative than redirecting to the
+        static badge enpoint or generating SVG on your server:
+      </p>
+      <ol>
+        <li>
+          <a href="https://en.wikipedia.org/wiki/Separation_of_content_and_presentation">
+            Content and presentation are separate.
+          </a>{' '}
+          The service provider authors the badge, and Shields takes input from
+          the user to format it. As a service provider you author the badge but
+          don't have to concern yourself with styling. You don't even have to
+          pass the formatting options through to Shields.
+        </li>
+        <li>
+          Badge formatting is always 100% up to date. There's no need to track
+          updates to the npm package, badge templates, or options.
+        </li>
+        <li>
+          A JSON response is easy to implement; easier than an HTTP redirect. It
+          is trivial in almost any framework, and is more compatible with
+          hosting environments such as{' '}
+          <a href="https://runkit.com/docs/endpoint">RunKit endpoints</a>.
+        </li>
+        <li>
+          As a service provider you can rely on the Shields CDN. There's no need
+          to study the HTTP headers. Adjusting cache behavior is as simple as
+          setting a property in the JSON response.
+        </li>
+      </ol>
+    </Explanation>
+    <h4>Schema</h4>
+    <p>
+      The schema may change during the beta period. Any changes will be posted
+      here. After launch, breaking changes will trigger an increment to the
+      `schemaVersion`.
+    </p>
+    <Schema>
+      <dt>schemaVersion</dt>
+      <dd>
+        Required. Always the number <code>1</code>.
+      </dd>
+      <dt>label</dt>
+      <dd>
+        Required. The left text, or the empty string to omit the left side of
+        the badge. This can be overridden by the query string.
+      </dd>
+      <dt>message</dt>
+      <dd>Required. Can't be empty. The right text.</dd>
+      <dt>color</dt>
+      <dd>
+        Default: <code>lightgrey</code>. The right color. Supports the eight
+        named colors above, as well as hex, rgb, rgba, hsl, hsla and css named
+        colors.
+      </dd>
+      <dt>labelColor</dt>
+      <dd>
+        Default: <code>grey</code>. The left color.
+      </dd>
+      <dt>isError</dt>
+      <dd>
+        Default: <code>false</code>. <code>true</code> to treat this as an error
+        badge. This prevents the user from overriding the color. In the future
+        it may affect cache behavior.
+      </dd>
+      <dt>namedLogo</dt>
+      <dd>
+        Default: none. One of the named logos supported by Shields or {}
+        <a href="https://simpleicons.org/">simple-icons</a>. Can be overridden
+        by the query string.
+      </dd>
+      <dt>logoSvg</dt>
+      <dd>Default: none. An SVG string containing a custom logo.</dd>
+      <dt>logoColor</dt>
+      <dd>
+        Default: none. Same meaning as the query string. Can be overridden by
+        the query string.
+      </dd>
+      <dt>logoWidth</dt>
+      <dd>
+        Default: none. Same meaning as the query string. Can be overridden by
+        the query string.
+      </dd>
+      <dt>logoPosition</dt>
+      <dd>
+        Default: none. Same meaning as the query string. Can be overridden by
+        the query string.
+      </dd>
+      <dt>style</dt>
+      <dd>
+        Default: <code>flat</code>. The default template to use. Can be
+        overridden by the query string.
+      </dd>
+      <dt>cacheSeconds</dt>
+      <dd>
+        Default: <code>300</code>. Set the HTTP cache lifetime in seconds, which
+        should respected by the Shields' CDN and downstream users. This lets you
+        tune performance and traffic vs. responsiveness. Can be overridden by
+        the user via the query string, but only to a longer value.
+      </dd>
+    </Schema>
+    <Footer baseUrl={baseUrl} />
+  </div>
+)
+export default EndpointPage
diff --git a/frontend/components/usage.js b/frontend/components/usage.js
index 1485ff74fe..55cf369285 100644
--- a/frontend/components/usage.js
+++ b/frontend/components/usage.js
@@ -1,4 +1,5 @@
 import React, { Fragment } from 'react'
+import { Link } from 'react-router-dom'
 import PropTypes from 'prop-types'
 import styled from 'styled-components'
 import { staticBadgeUrl } from '../lib/badge-url'
@@ -193,6 +194,19 @@ export default class Usage extends React.PureComponent {
         {this.constructor.renderStaticBadgeEscapingRules()}
         {this.renderColorExamples()}
 
+        <H3 id="endpoint">Endpoint (Beta)</H3>
+
+        <p>
+          <Snippet
+            snippet={`${baseUrl}/badge/endpoint.svg?url=<URL>&style<STYLE>`}
+          />
+        </p>
+
+        <p>
+          Create badges from{' '}
+          <Link to={'/endpoint'}>your own JSON endpoint</Link>.
+        </p>
+
         <H3 id="dynamic-badge">Dynamic</H3>
 
         <DynamicBadgeMaker baseUrl={baseUrl} />
diff --git a/pages/index.js b/pages/index.js
index 90cd8635e6..5ba840123b 100644
--- a/pages/index.js
+++ b/pages/index.js
@@ -1,6 +1,7 @@
 import React from 'react'
 import { HashRouter, StaticRouter, Route } from 'react-router-dom'
 import Main from '../frontend/components/main'
+import EndpointPage from '../frontend/components/endpoint-page'
 
 export default class Router extends React.Component {
   render() {
@@ -8,6 +9,7 @@ export default class Router extends React.Component {
       <div>
         <Route path="/" exact component={Main} />
         <Route path="/examples/:category" component={Main} />
+        <Route path="/endpoint" component={EndpointPage} />
       </div>
     )
 
diff --git a/services/base.js b/services/base.js
index c2067ec3d3..d876529003 100644
--- a/services/base.js
+++ b/services/base.js
@@ -32,6 +32,15 @@ const defaultBadgeDataSchema = Joi.object({
   namedLogo: Joi.string(),
 }).required()
 
+const optionalStringWhenNamedLogoPrsent = Joi.alternatives().when('namedLogo', {
+  is: Joi.string().required(),
+  then: Joi.string(),
+})
+
+const optionalNumberWhenAnyLogoPresent = Joi.alternatives()
+  .when('namedLogo', { is: Joi.string().required(), then: Joi.number() })
+  .when('logoSvg', { is: Joi.string().required(), then: Joi.number() })
+
 const serviceDataSchema = Joi.object({
   isError: Joi.boolean(),
   label: Joi.string().allow(''),
@@ -45,27 +54,15 @@ const serviceDataSchema = Joi.object({
   labelColor: Joi.string(),
   namedLogo: Joi.string(),
   logoSvg: Joi.string(),
-  logoColor: Joi.forbidden(),
-  logoWidth: Joi.forbidden(),
-  logoPosition: Joi.forbidden(),
-  cacheLengthSeconds: Joi.number()
+  logoColor: optionalStringWhenNamedLogoPrsent,
+  logoWidth: optionalNumberWhenAnyLogoPresent,
+  logoPosition: optionalNumberWhenAnyLogoPresent,
+  cacheSeconds: Joi.number()
     .integer()
     .min(0),
+  style: Joi.string(),
 })
   .oxor('namedLogo', 'logoSvg')
-  .when(
-    Joi.alternatives().try(
-      Joi.object({ namedLogo: Joi.string().required() }).unknown(),
-      Joi.object({ logoSvg: Joi.string().required() }).unknown()
-    ),
-    {
-      then: Joi.object({
-        logoColor: Joi.string(),
-        logoWidth: Joi.number(),
-        logoPosition: Joi.number(),
-      }),
-    }
-  )
   .required()
 
 class BaseService {
@@ -379,7 +376,7 @@ class BaseService {
   //    string.
   static _makeBadgeData(overrides, serviceData) {
     const {
-      style,
+      style: overrideStyle,
       label: overrideLabel,
       logoColor: overrideLogoColor,
       link: overrideLink,
@@ -415,7 +412,8 @@ class BaseService {
       logoWidth: serviceLogoWidth,
       logoPosition: serviceLogoPosition,
       link: serviceLink,
-      cacheLengthSeconds: serviceCacheLengthSeconds,
+      cacheSeconds: serviceCacheSeconds,
+      style: serviceStyle,
     } = serviceData
     const serviceLogoSvgBase64 = serviceLogoSvg
       ? svg2base64(serviceLogoSvg)
@@ -427,7 +425,9 @@ class BaseService {
       label: defaultLabel,
       labelColor: defaultLabelColor,
     } = this.defaultBadgeData
-    const defaultCacheLengthSeconds = this._cacheLength
+    const defaultCacheSeconds = this._cacheLength
+
+    const style = coalesce(overrideStyle, serviceStyle)
 
     const namedLogoSvgBase64 = prepareNamedLogo({
       name: coalesce(
@@ -480,10 +480,7 @@ class BaseService {
         overrideNamedLogo ? undefined : serviceLogoPosition
       ),
       links: toArray(overrideLink || serviceLink),
-      cacheLengthSeconds: coalesce(
-        serviceCacheLengthSeconds,
-        defaultCacheLengthSeconds
-      ),
+      cacheLengthSeconds: coalesce(serviceCacheSeconds, defaultCacheSeconds),
     }
   }
 
@@ -517,13 +514,14 @@ class BaseService {
     )
   }
 
-  static _validate(data, schema) {
+  static _validate(data, schema, { allowAndStripUnknownKeys = true } = {}) {
     return validate(
       {
         ErrorClass: InvalidResponse,
         prettyErrorMessage: 'invalid response data',
         traceErrorMessage: 'Response did not match schema',
         traceSuccessMessage: 'Response after validation',
+        allowAndStripUnknownKeys,
       },
       data,
       schema
diff --git a/services/base.spec.js b/services/base.spec.js
index 506d51442d..f0b9d2cacd 100644
--- a/services/base.spec.js
+++ b/services/base.spec.js
@@ -507,7 +507,7 @@ describe('BaseService', function() {
       it('overrides the cache length', function() {
         const badgeData = DummyService._makeBadgeData(
           { style: 'pill' },
-          { cacheLengthSeconds: 123 }
+          { cacheSeconds: 123 }
         )
         expect(badgeData.cacheLengthSeconds).to.equal(123)
       })
diff --git a/services/cache-headers.js b/services/cache-headers.js
index a9370a290f..58ecc7e192 100644
--- a/services/cache-headers.js
+++ b/services/cache-headers.js
@@ -12,7 +12,9 @@ const queryParamSchema = Joi.object({
   maxAge: Joi.number()
     .integer()
     .min(0),
-}).required()
+})
+  .unknown(true)
+  .required()
 
 function overrideCacheLengthFromQueryParams(queryParams) {
   try {
diff --git a/services/cache-headers.spec.js b/services/cache-headers.spec.js
index 71681b76b8..a2806065a2 100644
--- a/services/cache-headers.spec.js
+++ b/services/cache-headers.spec.js
@@ -35,6 +35,11 @@ describe('Cache header functions', function() {
         serviceDefaultCacheLengthSeconds: 900,
         queryParams: { maxAge: 1000 },
       }).expect(1000)
+      given({
+        cacheHeaderConfig,
+        serviceDefaultCacheLengthSeconds: 900,
+        queryParams: { maxAge: 1000, other: 'here', maybe: 'bogus' },
+      }).expect(1000)
       given({
         cacheHeaderConfig,
         serviceDefaultCacheLengthSeconds: 900,
diff --git a/services/endpoint/endpoint.service.js b/services/endpoint/endpoint.service.js
new file mode 100644
index 0000000000..ed9fa31ce8
--- /dev/null
+++ b/services/endpoint/endpoint.service.js
@@ -0,0 +1,132 @@
+'use strict'
+
+const { URL } = require('url')
+const Joi = require('joi')
+const { errorMessages } = require('../dynamic/dynamic-helpers')
+const BaseJsonService = require('../base-json')
+const { InvalidParameter } = require('../errors')
+const { optionalUrl } = require('../validators')
+
+const blockedDomains = ['github.com', 'shields.io']
+
+const queryParamSchema = Joi.object({
+  url: optionalUrl.required(),
+}).required()
+
+const anySchema = Joi.any()
+
+const optionalStringWhenNamedLogoPresent = Joi.alternatives().when(
+  'namedLogo',
+  {
+    is: Joi.string().required(),
+    then: Joi.string(),
+  }
+)
+
+const optionalNumberWhenAnyLogoPresent = Joi.alternatives()
+  .when('namedLogo', { is: Joi.string().required(), then: Joi.number() })
+  .when('logoSvg', { is: Joi.string().required(), then: Joi.number() })
+
+const endpointSchema = Joi.object({
+  schemaVersion: 1,
+  label: Joi.string()
+    .allow('')
+    .required(),
+  message: Joi.string().required(),
+  color: Joi.string(),
+  labelColor: Joi.string(),
+  isError: Joi.boolean().default(false),
+  namedLogo: Joi.string(),
+  logoSvg: Joi.string(),
+  logoColor: optionalStringWhenNamedLogoPresent,
+  logoWidth: optionalNumberWhenAnyLogoPresent,
+  logoPosition: optionalNumberWhenAnyLogoPresent,
+  style: Joi.string(),
+  cacheSeconds: Joi.number()
+    .integer()
+    .min(0),
+})
+  // `namedLogo` or `logoSvg`; not both.
+  .oxor('namedLogo', 'logoSvg')
+  .required()
+
+module.exports = class Endpoint extends BaseJsonService {
+  static get category() {
+    return 'dynamic'
+  }
+
+  static get route() {
+    return {
+      base: 'badge/endpoint',
+      pattern: '',
+      queryParams: ['url'],
+    }
+  }
+
+  static get _cacheLength() {
+    return 300
+  }
+
+  static get defaultBadgeData() {
+    return {
+      label: 'custom badge',
+    }
+  }
+
+  static render({
+    label,
+    message,
+    color,
+    labelColor,
+    namedLogo,
+    logoSvg,
+    logoColor,
+    logoWidth,
+    logoPosition,
+    style,
+    isError,
+    cacheSeconds,
+  }) {
+    return {
+      isError,
+      label,
+      message,
+      color,
+      labelColor,
+      namedLogo,
+      logoSvg,
+      logoColor,
+      logoWidth,
+      logoPosition,
+      style,
+      cacheSeconds,
+    }
+  }
+
+  async handle(namedParams, queryParams) {
+    const { url } = this.constructor._validateQueryParams(
+      queryParams,
+      queryParamSchema
+    )
+
+    const { protocol, hostname } = new URL(url)
+    if (protocol !== 'https:') {
+      throw new InvalidParameter({ prettyMessage: 'please use https' })
+    }
+    if (blockedDomains.some(domain => hostname.endsWith(domain))) {
+      throw new InvalidParameter({ prettyMessage: 'domain is blocked' })
+    }
+
+    const json = await this._requestJson({
+      schema: anySchema,
+      url,
+      errorMessages,
+    })
+    // Override the validation options because we want to reject unknown keys.
+    const validated = this.constructor._validate(json, endpointSchema, {
+      allowAndStripUnknownKeys: false,
+    })
+
+    return this.constructor.render(validated)
+  }
+}


Test patch:
diff --git a/gh-badges/templates/_shields_test-template.json b/gh-badges/templates/_shields_test-template.json
index e39706a956..af44570bba 100644
--- a/gh-badges/templates/_shields_test-template.json
+++ b/gh-badges/templates/_shields_test-template.json
@@ -2,6 +2,9 @@
   "color": {{=JSON.stringify(it.color || null)}},
 {{?it.labelColor}}
   "labelColor": {{=JSON.stringify(it.labelColor)}},
+{{?}}
+{{?it.logoWidth}}
+  "logoWidth": {{=JSON.stringify(it.logoWidth)}},
 {{?}}
   "name": {{=JSON.stringify(it.text[0])}},
   "value": {{=JSON.stringify(it.text[1])}}
diff --git a/services/endpoint/endpoint.tester.js b/services/endpoint/endpoint.tester.js
new file mode 100644
index 0000000000..92a05e0e77
--- /dev/null
+++ b/services/endpoint/endpoint.tester.js
@@ -0,0 +1,257 @@
+'use strict'
+
+const { expect } = require('chai')
+const { getShieldsIcon } = require('../../lib/logos')
+
+const t = (module.exports = require('../create-service-tester')())
+
+t.create('Valid schema (mocked)')
+  .get('.json?url=https://example.com/badge')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: '',
+        message: 'yo',
+      })
+  )
+  .expectJSON({ name: '', value: 'yo' })
+
+t.create('color and labelColor')
+  .get('.json?url=https://example.com/badge&style=_shields_test')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: 'hey',
+        message: 'yo',
+        color: '#f0dcc3',
+        labelColor: '#e6e6fa',
+      })
+  )
+  .expectJSON({
+    name: 'hey',
+    value: 'yo',
+    color: '#f0dcc3',
+    labelColor: '#e6e6fa',
+  })
+
+t.create('style')
+  .get('.json?url=https://example.com/badge')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: 'hey',
+        message: 'yo',
+        color: '#99c',
+        style: '_shields_test',
+      })
+  )
+  .expectJSON({
+    name: 'hey',
+    value: 'yo',
+    // `color` is only in _shields_test which is being specified by the
+    // service, not the request. If the color key is here we know this has
+    // worked.
+    color: '#99c',
+  })
+
+t.create('named logo')
+  .get('.svg?url=https://example.com/badge')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: 'hey',
+        message: 'yo',
+        namedLogo: 'github',
+      })
+  )
+  .after((err, res, body) => {
+    expect(err).not.to.be.ok
+    expect(body).to.include(getShieldsIcon({ name: 'github' }))
+  })
+
+t.create('named logo with color')
+  .get('.svg?url=https://example.com/badge')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: 'hey',
+        message: 'yo',
+        namedLogo: 'github',
+        logoColor: 'blue',
+      })
+  )
+  .after((err, res, body) => {
+    expect(err).not.to.be.ok
+    expect(body).to.include(getShieldsIcon({ name: 'github', color: 'blue' }))
+  })
+
+const logoSvg = Buffer.from(
+  getShieldsIcon({ name: 'github' }).replace('data:image/svg+xml;base64,', ''),
+  'base64'
+).toString('ascii')
+
+t.create('custom svg logo')
+  .get('.svg?url=https://example.com/badge')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: 'hey',
+        message: 'yo',
+        logoSvg,
+      })
+  )
+  .after((err, res, body) => {
+    expect(err).not.to.be.ok
+    expect(body).to.include(getShieldsIcon({ name: 'github' }))
+  })
+
+t.create('logoWidth')
+  .get('.json?url=https://example.com/badge&style=_shields_test')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: 'hey',
+        message: 'yo',
+        logoSvg,
+        logoWidth: 30,
+      })
+  )
+  .expectJSON({
+    name: 'hey',
+    value: 'yo',
+    color: 'lightgrey',
+    logoWidth: 30,
+  })
+
+t.create('Invalid schema (mocked)')
+  .get('.json?url=https://example.com/badge')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: -1,
+      })
+  )
+  .expectJSON({ name: 'custom badge', value: 'invalid response data' })
+
+t.create('Invalid schema (mocked)')
+  .get('.json?url=https://example.com/badge')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: 'hey',
+        message: 'yo',
+        extra: 'keys',
+        bogus: true,
+      })
+  )
+  .expectJSON({ name: 'custom badge', value: 'invalid response data' })
+
+t.create('User color overrides success color')
+  .get('.json?url=https://example.com/badge&colorB=101010&style=_shields_test')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: '',
+        message: 'yo',
+        color: 'blue',
+      })
+  )
+  .expectJSON({ name: '', value: 'yo', color: '#101010' })
+
+t.create('User color does not override error color')
+  .get('.json?url=https://example.com/badge&colorB=101010&style=_shields_test')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        isError: true,
+        label: 'something is',
+        message: 'not right',
+        color: 'red',
+      })
+  )
+  .expectJSON({ name: 'something is', value: 'not right', color: 'red' })
+
+t.create('cacheSeconds')
+  .get('.json?url=https://example.com/badge')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: '',
+        message: 'yo',
+        cacheSeconds: 500,
+      })
+  )
+  .expectHeader('cache-control', 'max-age=500')
+
+t.create('user can override service cacheSeconds')
+  .get('.json?url=https://example.com/badge&maxAge=1000')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: '',
+        message: 'yo',
+        cacheSeconds: 500,
+      })
+  )
+  .expectHeader('cache-control', 'max-age=1000')
+
+t.create('user does not override longer service cacheSeconds')
+  .get('.json?url=https://example.com/badge&maxAge=450')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: '',
+        message: 'yo',
+        cacheSeconds: 500,
+      })
+  )
+  .expectHeader('cache-control', 'max-age=500')
+
+t.create('cacheSeconds does not override longer Shields default')
+  .get('.json?url=https://example.com/badge')
+  .intercept(nock =>
+    nock('https://example.com/')
+      .get('/badge')
+      .reply(200, {
+        schemaVersion: 1,
+        label: '',
+        message: 'yo',
+        cacheSeconds: 10,
+      })
+  )
+  .expectHeader('cache-control', 'max-age=300')
+
+t.create('Bad scheme')
+  .get('.json?url=http://example.com/badge')
+  .expectJSON({ name: 'custom badge', value: 'please use https' })
+
+t.create('Blocked domain')
+  .get('.json?url=https://img.shields.io/badge/foo-bar-blue.json')
+  .expectJSON({ name: 'custom badge', value: 'domain is blocked' })