diff --git a/config/custom-environment-variables.yml b/config/custom-environment-variables.yml index ec769d2eff73615d331974638fc117d4b6ec351d..56919b467f1a48eebd55374ccf60fb618a90cd8b 100644 --- a/config/custom-environment-variables.yml +++ b/config/custom-environment-variables.yml @@ -79,6 +79,8 @@ private: bitbucket_server_password: 'BITBUCKET_SERVER_PASS' curseforge_api_key: 'CURSEFORGE_API_KEY' discord_bot_token: 'DISCORD_BOT_TOKEN' + dockerhub_username: 'DOCKERHUB_USER' + dockerhub_pat: 'DOCKERHUB_PAT' drone_token: 'DRONE_TOKEN' gh_client_id: 'GH_CLIENT_ID' gh_client_secret: 'GH_CLIENT_SECRET' diff --git a/core/base-service/auth-helper.js b/core/base-service/auth-helper.js index 069291b512cbcbb7c30dfcd60b8e93ae08a8d507..4fadbc99bcd58354be7b434b8f57212c6be46bca 100644 --- a/core/base-service/auth-helper.js +++ b/core/base-service/auth-helper.js @@ -1,5 +1,13 @@ import { URL } from 'url' -import { InvalidParameter } from './errors.js' +import dayjs from 'dayjs' +import Joi from 'joi' +import checkErrorResponse from './check-error-response.js' +import { InvalidParameter, InvalidResponse } from './errors.js' +import { fetch } from './got.js' +import { parseJson } from './json.js' +import validate from './validate.js' + +let jwtCache = Object.create(null) class AuthHelper { constructor( @@ -87,7 +95,7 @@ class AuthHelper { } } - shouldAuthenticateRequest({ url, options = {} }) { + isAllowedOrigin(url) { let parsed try { parsed = new URL(url) @@ -97,7 +105,11 @@ class AuthHelper { const { protocol, host } = parsed const origin = `${protocol}//${host}` - const originViolation = !this._authorizedOrigins.includes(origin) + return this._authorizedOrigins.includes(origin) + } + + shouldAuthenticateRequest({ url, options = {} }) { + const originViolation = !this.isAllowedOrigin(url) const strictSslCheckViolation = this._requireStrictSslToAuthenticate && @@ -218,6 +230,103 @@ class AuthHelper { }), ) } + + static _getJwtExpiry(token, max = dayjs().add(1, 'hours').unix()) { + // get the expiry timestamp for this JWT (capped at a max length) + const parts = token.split('.') + + if (parts.length < 2) { + throw new InvalidResponse({ + prettyMessage: 'invalid response data from auth endpoint', + }) + } + + const json = validate( + { + ErrorClass: InvalidResponse, + prettyErrorMessage: 'invalid response data from auth endpoint', + }, + parseJson(Buffer.from(parts[1], 'base64').toString()), + Joi.object({ exp: Joi.number().required() }).required(), + ) + + return Math.min(json.exp, max) + } + + static _isJwtValid(expiry) { + // we consider the token valid if the expiry + // datetime is later than (now + 1 minute) + return dayjs.unix(expiry).isAfter(dayjs().add(1, 'minutes')) + } + + async _getJwt(loginEndpoint) { + const { _user: username, _pass: password } = this + + // attempt to get JWT from cache + if ( + jwtCache?.[loginEndpoint]?.[username]?.token && + jwtCache?.[loginEndpoint]?.[username]?.expiry && + this.constructor._isJwtValid(jwtCache[loginEndpoint][username].expiry) + ) { + // cache hit + return jwtCache[loginEndpoint][username].token + } + + // cache miss - request a new JWT + const originViolation = !this.isAllowedOrigin(loginEndpoint) + if (originViolation) { + throw new InvalidParameter({ + prettyMessage: 'requested origin not authorized', + }) + } + + const { buffer } = await checkErrorResponse({})( + await fetch(loginEndpoint, { + method: 'POST', + form: { username, password }, + }), + ) + + const json = validate( + { + ErrorClass: InvalidResponse, + prettyErrorMessage: 'invalid response data from auth endpoint', + }, + parseJson(buffer), + Joi.object({ token: Joi.string().required() }).required(), + ) + + const token = json.token + const expiry = this.constructor._getJwtExpiry(token) + + // store in the cache + if (!(loginEndpoint in jwtCache)) { + jwtCache[loginEndpoint] = {} + } + jwtCache[loginEndpoint][username] = { token, expiry } + + return token + } + + async _getJwtAuthHeader(loginEndpoint) { + if (!this.isConfigured) { + return undefined + } + + const token = await this._getJwt(loginEndpoint) + return { Authorization: `Bearer ${token}` } + } + + async withJwtAuth(requestParams, loginEndpoint) { + const authHeader = await this._getJwtAuthHeader(loginEndpoint) + return this._withAnyAuth(requestParams, requestParams => + this.constructor._mergeHeaders(requestParams, authHeader), + ) + } +} + +function clearJwtCache() { + jwtCache = Object.create(null) } -export { AuthHelper } +export { AuthHelper, clearJwtCache } diff --git a/core/base-service/auth-helper.spec.js b/core/base-service/auth-helper.spec.js index 4ea06374ec8c91ceb25b803df53974f0f4a5e7f7..beb4142eb2e88419727fbefba75804f42e86050d 100644 --- a/core/base-service/auth-helper.spec.js +++ b/core/base-service/auth-helper.spec.js @@ -1,7 +1,32 @@ +import dayjs from 'dayjs' +import nock from 'nock' import { expect } from 'chai' import { test, given, forCases } from 'sazerac' -import { AuthHelper } from './auth-helper.js' -import { InvalidParameter } from './errors.js' +import { AuthHelper, clearJwtCache } from './auth-helper.js' +import { InvalidParameter, InvalidResponse } from './errors.js' + +function base64UrlEncode(input) { + const base64 = btoa(JSON.stringify(input)) + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function getMockJwt(extras) { + // this function returns a mock JWT that contains enough + // for a unit test but ignores important aspects e.g: signing + + const header = { + alg: 'HS256', + typ: 'JWT', + } + const payload = { + iat: Math.floor(Date.now() / 1000), + ...extras, + } + + const encodedHeader = base64UrlEncode(header) + const encodedPayload = base64UrlEncode(payload) + return `${encodedHeader}.${encodedPayload}` +} describe('AuthHelper', function () { describe('constructor checks', function () { @@ -381,4 +406,153 @@ describe('AuthHelper', function () { ).to.throw(InvalidParameter) }) }) + + context('JTW Auth', function () { + describe('_isJwtValid', function () { + test(AuthHelper._isJwtValid, () => { + given(dayjs().add(1, 'month').unix()).expect(true) + given(dayjs().add(2, 'minutes').unix()).expect(true) + given(dayjs().add(30, 'seconds').unix()).expect(false) + given(dayjs().unix()).expect(false) + given(dayjs().subtract(1, 'seconds').unix()).expect(false) + }) + }) + + describe('_getJwtExpiry', function () { + it('extracts expiry from valid JWT', function () { + const nowPlus30Mins = dayjs().add(30, 'minutes').unix() + expect( + AuthHelper._getJwtExpiry(getMockJwt({ exp: nowPlus30Mins })), + ).to.equal(nowPlus30Mins) + }) + + it('caps expiry at max', function () { + const nowPlus1Hour = dayjs().add(1, 'hours').unix() + const nowPlus2Hours = dayjs().add(2, 'hours').unix() + expect( + AuthHelper._getJwtExpiry(getMockJwt({ exp: nowPlus2Hours })), + ).to.equal(nowPlus1Hour) + }) + + it('throws if JWT does not contain exp', function () { + expect(() => { + AuthHelper._getJwtExpiry(getMockJwt({})) + }).to.throw(InvalidResponse) + }) + + it('throws if JWT is invalid', function () { + expect(() => { + AuthHelper._getJwtExpiry('abc') + }).to.throw(InvalidResponse) + }) + }) + + describe('withJwtAuth', function () { + const authHelper = new AuthHelper( + { + userKey: 'jwt_user', + passKey: 'jwt_pass', + authorizedOrigins: ['https://example.com'], + isRequired: false, + }, + { private: { jwt_user: 'fred', jwt_pass: 'abc123' } }, + ) + + beforeEach(function () { + clearJwtCache() + }) + + it('should use cached response if valid', async function () { + // the expiry is far enough in the future that the token + // will still be valid on the second hit + const mockToken = getMockJwt({ exp: dayjs().add(1, 'hours').unix() }) + + // .times(1) ensures if we try to make a second call to this endpoint, + // we will throw `Nock: No match for request` + nock('https://example.com') + .post('/login') + .times(1) + .reply(200, { token: mockToken }) + const params1 = await authHelper.withJwtAuth( + { url: 'https://example.com/some-endpoint' }, + 'https://example.com/login', + ) + expect(nock.isDone()).to.equal(true) + expect(params1).to.deep.equal({ + options: { + headers: { + Authorization: `Bearer ${mockToken}`, + }, + }, + url: 'https://example.com/some-endpoint', + }) + + // second time round, we'll get the same response again + // but this time served from cache + const params2 = await authHelper.withJwtAuth( + { url: 'https://example.com/some-endpoint' }, + 'https://example.com/login', + ) + expect(params2).to.deep.equal({ + options: { + headers: { + Authorization: `Bearer ${mockToken}`, + }, + }, + url: 'https://example.com/some-endpoint', + }) + + nock.cleanAll() + }) + + it('should not use cached response if expired', async function () { + // this time we define a token expiry is close enough + // that the token will not be valid on the second call + const mockToken1 = getMockJwt({ + exp: dayjs().add(20, 'seconds').unix(), + }) + nock('https://example.com') + .post('/login') + .times(1) + .reply(200, { token: mockToken1 }) + const params1 = await authHelper.withJwtAuth( + { url: 'https://example.com/some-endpoint' }, + 'https://example.com/login', + ) + expect(nock.isDone()).to.equal(true) + expect(params1).to.deep.equal({ + options: { + headers: { + Authorization: `Bearer ${mockToken1}`, + }, + }, + url: 'https://example.com/some-endpoint', + }) + + // second time round we make another network request + const mockToken2 = getMockJwt({ + exp: dayjs().add(20, 'seconds').unix(), + }) + nock('https://example.com') + .post('/login') + .times(1) + .reply(200, { token: mockToken2 }) + const params2 = await authHelper.withJwtAuth( + { url: 'https://example.com/some-endpoint' }, + 'https://example.com/login', + ) + expect(nock.isDone()).to.equal(true) + expect(params2).to.deep.equal({ + options: { + headers: { + Authorization: `Bearer ${mockToken2}`, + }, + }, + url: 'https://example.com/some-endpoint', + }) + + nock.cleanAll() + }) + }) + }) }) diff --git a/core/server/server.js b/core/server/server.js index fb3c5ef090d69fabd7bec8e4b83ab1a55e235e9c..d6c6256a1165edb7fd64801d27be4baf807ffdb6 100644 --- a/core/server/server.js +++ b/core/server/server.js @@ -165,6 +165,8 @@ const privateConfigSchema = Joi.object({ azure_devops_token: Joi.string(), curseforge_api_key: Joi.string(), discord_bot_token: Joi.string(), + dockerhub_username: Joi.string(), + dockerhub_pat: Joi.string(), drone_token: Joi.string(), gh_client_id: Joi.string(), gh_client_secret: Joi.string(), diff --git a/doc/server-secrets.md b/doc/server-secrets.md index 636a7dcb47adee979df3d2b1fab069ff97e5e407..f7406b6d8bb8907bb0c429a4665ef5f1227771bd 100644 --- a/doc/server-secrets.md +++ b/doc/server-secrets.md @@ -119,6 +119,18 @@ Using a token for Discord is optional but will allow higher API rates. Register an application in the [Discord developer console](https://discord.com/developers). To obtain a token, simply create a bot for your application. +### DockerHub + +Using authentication for DockerHub is optional but can be used to allow +higher API rates or access to private repos. + +- `DOCKERHUB_USER` (yml: `private.dockerhub_username`) +- `DOCKERHUB_PAT` (yml: `private.dockerhub_pat`) + +`DOCKERHUB_PAT` is a Personal Access Token. Generate a token in your +[account security settings](https://hub.docker.com/settings/security) with +"Read-Only" or "Public Repo Read-Only", depending on your needs. + ### Drone - `DRONE_ORIGINS` (yml: `public.services.drone.authorizedOrigins`) diff --git a/services/docker/docker-automated.service.js b/services/docker/docker-automated.service.js index 0560e21c4bd327abfdc6b07b16dbce233c0976cc..bf8c946e371198d7ccd6d3136eb5f7dd335cb81e 100644 --- a/services/docker/docker-automated.service.js +++ b/services/docker/docker-automated.service.js @@ -5,6 +5,7 @@ import { buildDockerUrl, getDockerHubUser, } from './docker-helpers.js' +import { fetch } from './docker-hub-common-fetch.js' const automatedBuildSchema = Joi.object({ is_automated: Joi.boolean().required(), @@ -13,6 +14,17 @@ const automatedBuildSchema = Joi.object({ export default class DockerAutomatedBuild extends BaseJsonService { static category = 'build' static route = buildDockerUrl('automated') + + static auth = { + userKey: 'dockerhub_username', + passKey: 'dockerhub_pat', + authorizedOrigins: [ + 'https://hub.docker.com', + 'https://registry.hub.docker.com', + ], + isRequired: false, + } + static openApi = { '/docker/automated/{user}/{repo}': { get: { @@ -44,7 +56,7 @@ export default class DockerAutomatedBuild extends BaseJsonService { } async fetch({ user, repo }) { - return this._requestJson({ + return await fetch(this, { schema: automatedBuildSchema, url: `https://registry.hub.docker.com/v2/repositories/${getDockerHubUser( user, diff --git a/services/docker/docker-cloud-automated.service.js b/services/docker/docker-cloud-automated.service.js index f808cea3b686a7da996c841c922b4a6c595daecb..feb392994a7b69f558064a9894889a67a43fdcc0 100644 --- a/services/docker/docker-cloud-automated.service.js +++ b/services/docker/docker-cloud-automated.service.js @@ -5,6 +5,14 @@ import { fetchBuild } from './docker-cloud-common-fetch.js' export default class DockerCloudAutomatedBuild extends BaseJsonService { static category = 'build' static route = buildDockerUrl('cloud/automated') + + static auth = { + userKey: 'dockerhub_username', + passKey: 'dockerhub_pat', + authorizedOrigins: ['https://hub.docker.com', 'https://cloud.docker.com'], + isRequired: false, + } + static examples = [ { title: 'Docker Cloud Automated build', diff --git a/services/docker/docker-cloud-build.service.js b/services/docker/docker-cloud-build.service.js index 3435dbf3e6adceb83fe56ddfc1f42269b2cd779b..31713155a81825e9fe4c97f177abcfbb6d16b12b 100644 --- a/services/docker/docker-cloud-build.service.js +++ b/services/docker/docker-cloud-build.service.js @@ -5,6 +5,14 @@ import { fetchBuild } from './docker-cloud-common-fetch.js' export default class DockerCloudBuild extends BaseJsonService { static category = 'build' static route = buildDockerUrl('cloud/build') + + static auth = { + userKey: 'dockerhub_username', + passKey: 'dockerhub_pat', + authorizedOrigins: ['https://hub.docker.com', 'https://cloud.docker.com'], + isRequired: false, + } + static examples = [ { title: 'Docker Cloud Build Status', diff --git a/services/docker/docker-cloud-common-fetch.js b/services/docker/docker-cloud-common-fetch.js index 60e12ffb0b38a6daab1fb3116dfcd954193dcccd..e340fbba2eacee1413b6bc2acf0af9535d860f3d 100644 --- a/services/docker/docker-cloud-common-fetch.js +++ b/services/docker/docker-cloud-common-fetch.js @@ -12,12 +12,17 @@ const cloudBuildSchema = Joi.object({ }).required() async function fetchBuild(serviceInstance, { user, repo }) { - return serviceInstance._requestJson({ - schema: cloudBuildSchema, - url: 'https://cloud.docker.com/api/build/v1/source', - options: { searchParams: { image: `${user}/${repo}` } }, - httpErrors: { 404: 'repo not found' }, - }) + return serviceInstance._requestJson( + await serviceInstance.authHelper.withJwtAuth( + { + schema: cloudBuildSchema, + url: 'https://cloud.docker.com/api/build/v1/source', + options: { searchParams: { image: `${user}/${repo}` } }, + httpErrors: { 404: 'repo not found' }, + }, + 'https://hub.docker.com/v2/users/login/', + ), + ) } export { fetchBuild } diff --git a/services/docker/docker-cloud-common-fetch.spec.js b/services/docker/docker-cloud-common-fetch.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..ada82d943f9071010152ea247a6ec977154ebf03 --- /dev/null +++ b/services/docker/docker-cloud-common-fetch.spec.js @@ -0,0 +1,23 @@ +import sinon from 'sinon' +import { expect } from 'chai' +import { fetchBuild } from './docker-cloud-common-fetch.js' + +describe('fetchBuild', function () { + it('invokes withJwtAuth', async function () { + const serviceInstance = { + _requestJson: sinon.stub().resolves('fake-response'), + authHelper: { + withJwtAuth: sinon.stub(), + }, + } + + const resp = await fetchBuild(serviceInstance, { + user: 'user', + repo: 'repo', + }) + + expect(serviceInstance.authHelper.withJwtAuth.calledOnce).to.be.true + expect(serviceInstance._requestJson.calledOnce).to.be.true + expect(resp).to.equal('fake-response') + }) +}) diff --git a/services/docker/docker-hub-common-fetch.js b/services/docker/docker-hub-common-fetch.js new file mode 100644 index 0000000000000000000000000000000000000000..c5ef9c39e59e7d893d31511c59cfebf54a0ec192 --- /dev/null +++ b/services/docker/docker-hub-common-fetch.js @@ -0,0 +1,10 @@ +async function fetch(serviceInstance, params) { + return serviceInstance._requestJson( + await serviceInstance.authHelper.withJwtAuth( + params, + 'https://hub.docker.com/v2/users/login/', + ), + ) +} + +export { fetch } diff --git a/services/docker/docker-hub-common-fetch.spec.js b/services/docker/docker-hub-common-fetch.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..0aac7416ca0ec17586a0dd2a077b36993975f0d5 --- /dev/null +++ b/services/docker/docker-hub-common-fetch.spec.js @@ -0,0 +1,20 @@ +import sinon from 'sinon' +import { expect } from 'chai' +import { fetch } from './docker-hub-common-fetch.js' + +describe('fetch', function () { + it('invokes withJwtAuth', async function () { + const serviceInstance = { + _requestJson: sinon.stub().resolves('fake-response'), + authHelper: { + withJwtAuth: sinon.stub(), + }, + } + + const resp = await fetch(serviceInstance, {}) + + expect(serviceInstance.authHelper.withJwtAuth.calledOnce).to.be.true + expect(serviceInstance._requestJson.calledOnce).to.be.true + expect(resp).to.equal('fake-response') + }) +}) diff --git a/services/docker/docker-pulls.service.js b/services/docker/docker-pulls.service.js index 630e6d452f173914e106bc154fdc925f07c805d3..520bc9e10744bd698682d6bc6d0f1682486de23e 100644 --- a/services/docker/docker-pulls.service.js +++ b/services/docker/docker-pulls.service.js @@ -7,6 +7,7 @@ import { buildDockerUrl, getDockerHubUser, } from './docker-helpers.js' +import { fetch } from './docker-hub-common-fetch.js' const pullsSchema = Joi.object({ pull_count: nonNegativeInteger, @@ -15,6 +16,14 @@ const pullsSchema = Joi.object({ export default class DockerPulls extends BaseJsonService { static category = 'downloads' static route = buildDockerUrl('pulls') + + static auth = { + userKey: 'dockerhub_username', + passKey: 'dockerhub_pat', + authorizedOrigins: ['https://hub.docker.com'], + isRequired: false, + } + static openApi = { '/docker/pulls/{user}/{repo}': { get: { @@ -42,7 +51,7 @@ export default class DockerPulls extends BaseJsonService { } async fetch({ user, repo }) { - return this._requestJson({ + return await fetch(this, { schema: pullsSchema, url: `https://hub.docker.com/v2/repositories/${getDockerHubUser( user, diff --git a/services/docker/docker-size.service.js b/services/docker/docker-size.service.js index 481483eaebacd6ab0af56d63c1ad0a6965e26e2a..8368a27a4e2119f8df10be16d8794dfc2cb75e8e 100644 --- a/services/docker/docker-size.service.js +++ b/services/docker/docker-size.service.js @@ -9,6 +9,7 @@ import { getDockerHubUser, getMultiPageData, } from './docker-helpers.js' +import { fetch } from './docker-hub-common-fetch.js' const buildSchema = Joi.object({ name: Joi.string().required(), @@ -61,6 +62,17 @@ function getImageSizeForArch(images, arch) { export default class DockerSize extends BaseJsonService { static category = 'size' static route = { ...buildDockerUrl('image-size', true), queryParamSchema } + + static auth = { + userKey: 'dockerhub_username', + passKey: 'dockerhub_pat', + authorizedOrigins: [ + 'https://hub.docker.com', + 'https://registry.hub.docker.com', + ], + isRequired: false, + } + static examples = [ { title: 'Docker Image Size (latest by date)', @@ -102,7 +114,7 @@ export default class DockerSize extends BaseJsonService { async fetch({ user, repo, tag, page }) { page = page ? `&page=${page}` : '' - return this._requestJson({ + return await fetch(this, { schema: tag ? buildSchema : pagedSchema, url: `https://registry.hub.docker.com/v2/repositories/${getDockerHubUser( user, diff --git a/services/docker/docker-stars.service.js b/services/docker/docker-stars.service.js index 6b696df1ab5b4c8e873e57af5eb7a3dbe5bb6b0a..ed03e4bd9c59dc03fdd25d39e0db2e3a5c9656f6 100644 --- a/services/docker/docker-stars.service.js +++ b/services/docker/docker-stars.service.js @@ -7,6 +7,7 @@ import { buildDockerUrl, getDockerHubUser, } from './docker-helpers.js' +import { fetch } from './docker-hub-common-fetch.js' const schema = Joi.object({ star_count: nonNegativeInteger.required(), @@ -15,6 +16,14 @@ const schema = Joi.object({ export default class DockerStars extends BaseJsonService { static category = 'rating' static route = buildDockerUrl('stars') + + static auth = { + userKey: 'dockerhub_username', + passKey: 'dockerhub_pat', + authorizedOrigins: ['https://hub.docker.com'], + isRequired: false, + } + static openApi = { '/docker/stars/{user}/{repo}': { get: { @@ -45,7 +54,7 @@ export default class DockerStars extends BaseJsonService { } async fetch({ user, repo }) { - return this._requestJson({ + return await fetch(this, { schema, url: `https://hub.docker.com/v2/repositories/${getDockerHubUser( user, diff --git a/services/docker/docker-version.service.js b/services/docker/docker-version.service.js index f3152432dcfffbfd0edc1d8d9c5f46e5765d112f..f611a15cdc52685057036657c911a81768cbc3f6 100644 --- a/services/docker/docker-version.service.js +++ b/services/docker/docker-version.service.js @@ -9,6 +9,7 @@ import { getMultiPageData, getDigestSemVerMatches, } from './docker-helpers.js' +import { fetch } from './docker-hub-common-fetch.js' const buildSchema = Joi.object({ count: nonNegativeInteger.required(), @@ -33,6 +34,17 @@ const queryParamSchema = Joi.object({ export default class DockerVersion extends BaseJsonService { static category = 'version' static route = { ...buildDockerUrl('v', true), queryParamSchema } + + static auth = { + userKey: 'dockerhub_username', + passKey: 'dockerhub_pat', + authorizedOrigins: [ + 'https://hub.docker.com', + 'https://registry.hub.docker.com', + ], + isRequired: false, + } + static examples = [ { title: 'Docker Image Version (latest by date)', @@ -64,7 +76,7 @@ export default class DockerVersion extends BaseJsonService { async fetch({ user, repo, page }) { page = page ? `&page=${page}` : '' - return this._requestJson({ + return await fetch(this, { schema: buildSchema, url: `https://registry.hub.docker.com/v2/repositories/${getDockerHubUser( user, diff --git a/services/docker/docker-version.tester.js b/services/docker/docker-version.tester.js index c737cb8abe1f65262c8e027a1f68cec8cf82de57..9ded09bf8496061b6adf57ed70f5c4ff0f0f6516 100644 --- a/services/docker/docker-version.tester.js +++ b/services/docker/docker-version.tester.js @@ -2,7 +2,7 @@ import { isSemver } from '../test-validators.js' import { createServiceTester } from '../tester.js' export const t = await createServiceTester() -t.create('docker version (valid, library)').get('/_/alpine.json').expectBadge({ +t.create('docker version (valid, library)').get('/_/redis.json').expectBadge({ label: 'version', message: isSemver, })