Compare commits
49 Commits
server-202
...
github-oau
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6e31d7f32 | ||
|
|
3aadb79325 | ||
|
|
b8412fd80b | ||
|
|
345188e34b | ||
|
|
a92dc72ff5 | ||
|
|
bf469f10df | ||
|
|
994e752fd1 | ||
|
|
f6fd8eac4a | ||
|
|
c41d45100e | ||
|
|
e66b266800 | ||
|
|
1ac7ccc231 | ||
|
|
d728749886 | ||
|
|
354fb7db99 | ||
|
|
961e13b229 | ||
|
|
6bb62e4c0b | ||
|
|
721d0142ff | ||
|
|
13a53f123f | ||
|
|
ca63f21113 | ||
|
|
042ae1c45f | ||
|
|
2f52b1617d | ||
|
|
e91da33016 | ||
|
|
a76df09c35 | ||
|
|
70874e2d5b | ||
|
|
68dbf71d42 | ||
|
|
f4bddb9964 | ||
|
|
cb52deec1c | ||
|
|
047b14b52a | ||
|
|
dfb68efffb | ||
|
|
8284545e22 | ||
|
|
8a1c69ead6 | ||
|
|
1b871a97b4 | ||
|
|
0342a3d7c6 | ||
|
|
77871a9f7b | ||
|
|
15be262ba5 | ||
|
|
05fe731290 | ||
|
|
58310f7363 | ||
|
|
570c2750e2 | ||
|
|
62af78c488 | ||
|
|
c48cd071fe | ||
|
|
a9c9e7d679 | ||
|
|
4a47b9a364 | ||
|
|
901dd7b9b6 | ||
|
|
6dff73065a | ||
|
|
a5f803ff2b | ||
|
|
9780da024e | ||
|
|
12b5e8891f | ||
|
|
5472c733a6 | ||
|
|
fcab8a52dc | ||
|
|
a111e9cba8 |
20
.github/workflows/build-docker-image.yml
vendored
Normal file
20
.github/workflows/build-docker-image.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: Build Docker Image
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
tags: shieldsio/shields:pr-validation
|
||||
@@ -40,6 +40,8 @@ public:
|
||||
debug:
|
||||
enabled: 'GITHUB_DEBUG_ENABLED'
|
||||
intervalSeconds: 'GITHUB_DEBUG_INTERVAL_SECONDS'
|
||||
gitlab:
|
||||
authorizedOrigins: 'GITLAB_ORIGINS'
|
||||
jenkins:
|
||||
authorizedOrigins: 'JENKINS_ORIGINS'
|
||||
jira:
|
||||
@@ -77,6 +79,7 @@ private:
|
||||
gh_client_id: 'GH_CLIENT_ID'
|
||||
gh_client_secret: 'GH_CLIENT_SECRET'
|
||||
gh_token: 'GH_TOKEN'
|
||||
gitlab_token: 'GITLAB_TOKEN'
|
||||
jenkins_user: 'JENKINS_USER'
|
||||
jenkins_pass: 'JENKINS_PASS'
|
||||
jira_user: 'JIRA_USER'
|
||||
|
||||
@@ -3,6 +3,7 @@ private:
|
||||
discord_bot_token: ...
|
||||
gh_client_id: ...
|
||||
gh_client_secret: ...
|
||||
gitlab_token: ...
|
||||
redis_url: ...
|
||||
sentry_dsn: ...
|
||||
shields_secret: ...
|
||||
|
||||
@@ -5,6 +5,7 @@ private:
|
||||
# you can also set these values through environment variables, which may be
|
||||
# preferable for self hosting.
|
||||
gh_token: '...'
|
||||
gitlab_token: '...'
|
||||
twitch_client_id: '...'
|
||||
twitch_client_secret: '...'
|
||||
weblate_api_key: '...'
|
||||
|
||||
@@ -125,6 +125,7 @@ const publicConfigSchema = Joi.object({
|
||||
intervalSeconds: Joi.number().integer().min(1).required(),
|
||||
},
|
||||
},
|
||||
gitlab: defaultService,
|
||||
jira: defaultService,
|
||||
jenkins: Joi.object({
|
||||
authorizedOrigins: origins,
|
||||
@@ -161,6 +162,7 @@ const privateConfigSchema = Joi.object({
|
||||
gh_client_id: Joi.string(),
|
||||
gh_client_secret: Joi.string(),
|
||||
gh_token: Joi.string(),
|
||||
gitlab_token: Joi.string(),
|
||||
jenkins_user: Joi.string(),
|
||||
jenkins_pass: Joi.string(),
|
||||
jira_user: Joi.string(),
|
||||
|
||||
@@ -188,6 +188,10 @@ class TokenPool {
|
||||
this.priorityQueue = new PriorityQueue(this.constructor.compareTokens)
|
||||
}
|
||||
|
||||
count() {
|
||||
return this.tokenIds.size
|
||||
}
|
||||
|
||||
/**
|
||||
* compareTokens
|
||||
*
|
||||
|
||||
@@ -32,6 +32,8 @@ Production hosting is managed by the Shields ops team:
|
||||
| Twitch | OAuth app | @PyvesB |
|
||||
| Discord | OAuth app | @PyvesB |
|
||||
| YouTube | Account owner | @PyvesB |
|
||||
| GitLab | Account owner | @calebcartwright |
|
||||
| GitLab | Account access | @calebcartwright, @chris48s, @paulmelnikow, @PyvesB |
|
||||
| OpenStreetMap (for Wheelmap) | Account owner | @paulmelnikow |
|
||||
| DNS | Account owner | @olivierlacan |
|
||||
| DNS | Read-only account access | @espadrine, @paulmelnikow, @chris48s |
|
||||
|
||||
@@ -147,6 +147,15 @@ These settings are used by shields.io for GitHub OAuth app authorization
|
||||
but will not be necessary for most self-hosted installations. See
|
||||
[production-hosting.md](./production-hosting.md).
|
||||
|
||||
### GitLab
|
||||
|
||||
- `GITLAB_ORIGINS` (yml: `public.services.gitlab.authorizedOrigins`)
|
||||
- `GITLAB_TOKEN` (yml: `private.gitlab_token`)
|
||||
|
||||
A GitLab [Personal Access Token][gitlab-pat] is required for accessing private content. If you need a GitLab token for your self-hosted Shields server then we recommend limiting the scopes to the minimal set necessary for the badges you are using.
|
||||
|
||||
[gitlab-pat]: https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html
|
||||
|
||||
### Jenkins CI
|
||||
|
||||
- `JENKINS_ORIGINS` (yml: `public.services.jenkins.authorizedOrigins`)
|
||||
|
||||
4200
package-lock.json
generated
4200
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
52
package.json
52
package.json
@@ -24,7 +24,7 @@
|
||||
"dependencies": {
|
||||
"@fontsource/lato": "^4.5.0",
|
||||
"@fontsource/lekton": "^4.5.0",
|
||||
"@sentry/node": "^6.11.0",
|
||||
"@sentry/node": "^6.12.0",
|
||||
"@shields_io/camp": "^18.1.1",
|
||||
"badge-maker": "file:badge-maker",
|
||||
"bytes": "^3.1.0",
|
||||
@@ -37,13 +37,13 @@
|
||||
"decamelize": "^5.0.0",
|
||||
"emojic": "^1.1.16",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"fast-xml-parser": "^3.19.0",
|
||||
"fast-xml-parser": "^3.20.0",
|
||||
"glob": "^7.1.7",
|
||||
"global-agent": "^3.0.0",
|
||||
"got": "11.8.2",
|
||||
"graphql": "^15.5.1",
|
||||
"graphql": "^15.5.3",
|
||||
"graphql-tag": "^2.12.5",
|
||||
"ioredis": "4.27.8",
|
||||
"ioredis": "4.27.9",
|
||||
"joi": "17.4.2",
|
||||
"joi-extension-semver": "5.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
@@ -62,7 +62,7 @@
|
||||
"query-string": "^7.0.1",
|
||||
"request": "~2.88.2",
|
||||
"semver": "~7.3.5",
|
||||
"simple-icons": "5.11.0",
|
||||
"simple-icons": "5.14.0",
|
||||
"webextension-store-meta": "^1.0.4",
|
||||
"xmldom": "~0.6.0",
|
||||
"xpath": "~0.0.32"
|
||||
@@ -142,7 +142,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.15.0",
|
||||
"@babel/core": "^7.15.5",
|
||||
"@babel/polyfill": "^7.12.1",
|
||||
"@babel/register": "7.15.3",
|
||||
"@mapbox/react-click-to-select": "^2.2.1",
|
||||
@@ -150,17 +150,17 @@
|
||||
"@types/lodash.debounce": "^4.0.6",
|
||||
"@types/lodash.groupby": "^4.6.6",
|
||||
"@types/mocha": "^9.0.0",
|
||||
"@types/node": "^16.7.2",
|
||||
"@types/node": "^16.7.10",
|
||||
"@types/react-helmet": "^6.1.2",
|
||||
"@types/react-modal": "^3.12.1",
|
||||
"@types/react-select": "^4.0.17",
|
||||
"@types/styled-components": "5.1.13",
|
||||
"@typescript-eslint/eslint-plugin": "^4.29.0",
|
||||
"@typescript-eslint/parser": "^4.29.0",
|
||||
"@types/styled-components": "5.1.14",
|
||||
"@typescript-eslint/eslint-plugin": "^4.31.0",
|
||||
"@typescript-eslint/parser": "^4.30.0",
|
||||
"babel-plugin-inline-react-svg": "^2.0.1",
|
||||
"babel-plugin-istanbul": "^6.0.0",
|
||||
"babel-preset-gatsby": "^1.2.0",
|
||||
"c8": "^7.8.0",
|
||||
"babel-preset-gatsby": "^1.13.0",
|
||||
"c8": "^7.9.0",
|
||||
"caller": "^1.0.1",
|
||||
"chai": "^4.3.4",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
@@ -169,7 +169,7 @@
|
||||
"child-process-promise": "^2.2.1",
|
||||
"clipboard-copy": "^4.0.1",
|
||||
"concurrently": "^6.2.1",
|
||||
"cypress": "^8.3.0",
|
||||
"cypress": "^8.4.0",
|
||||
"danger": "^10.6.6",
|
||||
"danger-plugin-no-test-shortcuts": "^2.0.0",
|
||||
"deepmerge": "^4.2.2",
|
||||
@@ -179,9 +179,9 @@
|
||||
"eslint-config-standard-jsx": "^10.0.0",
|
||||
"eslint-config-standard-react": "^11.0.1",
|
||||
"eslint-plugin-chai-friendly": "^0.7.2",
|
||||
"eslint-plugin-cypress": "^2.11.3",
|
||||
"eslint-plugin-cypress": "^2.12.1",
|
||||
"eslint-plugin-import": "^2.24.2",
|
||||
"eslint-plugin-jsdoc": "^36.0.8",
|
||||
"eslint-plugin-jsdoc": "^36.1.0",
|
||||
"eslint-plugin-mocha": "^9.0.0",
|
||||
"eslint-plugin-no-extension-in-require": "^0.2.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
@@ -191,12 +191,12 @@
|
||||
"eslint-plugin-sort-class-members": "^1.11.0",
|
||||
"fetch-ponyfill": "^7.1.0",
|
||||
"form-data": "^4.0.0",
|
||||
"gatsby": "3.12.1",
|
||||
"gatsby-plugin-catch-links": "^3.1.0",
|
||||
"gatsby-plugin-page-creator": "^3.12.0",
|
||||
"gatsby-plugin-react-helmet": "^4.1.0",
|
||||
"gatsby-plugin-remove-trailing-slashes": "^3.1.0",
|
||||
"gatsby-plugin-styled-components": "^4.6.0",
|
||||
"gatsby": "3.13.1",
|
||||
"gatsby-plugin-catch-links": "^3.13.0",
|
||||
"gatsby-plugin-page-creator": "^3.13.0",
|
||||
"gatsby-plugin-react-helmet": "^4.13.0",
|
||||
"gatsby-plugin-remove-trailing-slashes": "^3.13.0",
|
||||
"gatsby-plugin-styled-components": "^4.13.0",
|
||||
"gatsby-plugin-typescript": "^3.2.0",
|
||||
"humanize-string": "^2.1.0",
|
||||
"icedfrisby": "4.0.0",
|
||||
@@ -208,7 +208,7 @@
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.difference": "^4.5.0",
|
||||
"minimist": "^1.2.5",
|
||||
"mocha": "^9.1.0",
|
||||
"mocha": "^9.1.1",
|
||||
"mocha-env-reporter": "^4.0.0",
|
||||
"mocha-junit-reporter": "^2.0.0",
|
||||
"mocha-yaml-loader": "^1.0.3",
|
||||
@@ -216,9 +216,9 @@
|
||||
"node-mocks-http": "^1.10.1",
|
||||
"nodemon": "^2.0.12",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"open-cli": "^7.0.0",
|
||||
"open-cli": "^7.0.1",
|
||||
"portfinder": "^1.0.28",
|
||||
"prettier": "2.3.2",
|
||||
"prettier": "2.4.1",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-error-overlay": "^6.0.9",
|
||||
@@ -234,11 +234,11 @@
|
||||
"sinon": "^11.1.2",
|
||||
"sinon-chai": "^3.7.0",
|
||||
"snap-shot-it": "^7.9.6",
|
||||
"start-server-and-test": "1.13.1",
|
||||
"start-server-and-test": "1.14.0",
|
||||
"styled-components": "^5.3.1",
|
||||
"ts-mocha": "^8.0.0",
|
||||
"tsd": "^0.17.0",
|
||||
"typescript": "^4.4.2"
|
||||
"typescript": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.17.1",
|
||||
|
||||
@@ -15,7 +15,7 @@ t.create('docker version (valid, library with tag)')
|
||||
})
|
||||
|
||||
t.create('docker version (valid, user)')
|
||||
.get('/datadog/agent.json')
|
||||
.get('/datadog/dogstatsd.json')
|
||||
.expectBadge({
|
||||
label: 'version',
|
||||
message: isSemver,
|
||||
|
||||
@@ -2,11 +2,17 @@ import Joi from 'joi'
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { BaseJsonService, InvalidResponse, NotFound } from '../index.js'
|
||||
|
||||
/**
|
||||
* Validates that the schema response is what we're expecting.
|
||||
* The username pattern should match the freeCodeCamp repository.
|
||||
*
|
||||
* @see https://github.com/freeCodeCamp/freeCodeCamp/blob/main/utils/validate.js#L14
|
||||
*/
|
||||
const schema = Joi.object({
|
||||
entities: Joi.object({
|
||||
user: Joi.object()
|
||||
.required()
|
||||
.pattern(/^\w+$/, {
|
||||
.pattern(/^[a-zA-Z0-9\-_+]*$/, {
|
||||
points: Joi.number().allow(null).required(),
|
||||
}),
|
||||
}).optional(),
|
||||
|
||||
@@ -3,7 +3,7 @@ import request from 'request'
|
||||
import { userAgent } from '../../../core/base-service/legacy-request-handler.js'
|
||||
import log from '../../../core/server/log.js'
|
||||
|
||||
function setRoutes({ server, authHelper, onTokenAccepted }) {
|
||||
function setRoutes({ server, authHelper, onTokenAccepted, tokenScopes }) {
|
||||
const baseUrl = process.env.GATSBY_BASE_URL || 'https://img.shields.io'
|
||||
|
||||
server.route(/^\/github-auth$/, (data, match, end, ask) => {
|
||||
@@ -15,6 +15,7 @@ function setRoutes({ server, authHelper, onTokenAccepted }) {
|
||||
// it's not setting a bad example.
|
||||
client_id: authHelper._user,
|
||||
redirect_uri: `${baseUrl}/github-auth/done`,
|
||||
scope: tokenScopes,
|
||||
})
|
||||
ask.res.setHeader(
|
||||
'Location',
|
||||
|
||||
@@ -41,6 +41,7 @@ describe('Github token acceptor', function () {
|
||||
server: camp,
|
||||
authHelper: oauthHelper,
|
||||
onTokenAccepted,
|
||||
tokenScopes: 'read:packages',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,6 +53,7 @@ describe('Github token acceptor', function () {
|
||||
const qs = queryString.stringify({
|
||||
client_id: fakeClientId,
|
||||
redirect_uri: 'https://img.shields.io/github-auth/done',
|
||||
scope: 'read:packages',
|
||||
})
|
||||
const expectedLocationHeader = `https://github.com/login/oauth/authorize?${qs}`
|
||||
expect(res.headers.location).to.equal(expectedLocationHeader)
|
||||
|
||||
@@ -30,11 +30,10 @@ describe('Github API provider', function () {
|
||||
it('should be able to run 10 requests', async function () {
|
||||
this.timeout('20s')
|
||||
for (let i = 0; i < 10; ++i) {
|
||||
await githubApiProvider.requestAsPromise(
|
||||
await githubApiProvider.requestAsPromise({
|
||||
request,
|
||||
'/repos/rust-lang/rust',
|
||||
{}
|
||||
)
|
||||
url: '/repos/rust-lang/rust',
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -52,11 +51,10 @@ describe('Github API provider', function () {
|
||||
|
||||
const headers = []
|
||||
async function performOneRequest() {
|
||||
const { res } = await githubApiProvider.requestAsPromise(
|
||||
const { res } = await githubApiProvider.requestAsPromise({
|
||||
request,
|
||||
'/repos/rust-lang/rust',
|
||||
{}
|
||||
)
|
||||
url: '/repos/rust-lang/rust',
|
||||
})
|
||||
expect(res.statusCode).to.equal(200)
|
||||
headers.push(res.headers)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ class GithubApiProvider {
|
||||
onTokenInvalidated = tokenString => {},
|
||||
globalToken,
|
||||
reserveFraction = 0.25,
|
||||
tokenScopeNames = {},
|
||||
}) {
|
||||
Object.assign(this, {
|
||||
baseUrl,
|
||||
@@ -45,12 +46,14 @@ class GithubApiProvider {
|
||||
onTokenInvalidated,
|
||||
globalToken,
|
||||
reserveFraction,
|
||||
tokenScopeNames,
|
||||
})
|
||||
|
||||
if (this.withPooling) {
|
||||
this.standardTokens = new TokenPool({ batchSize: 25 })
|
||||
this.searchTokens = new TokenPool({ batchSize: 5 })
|
||||
this.graphqlTokens = new TokenPool({ batchSize: 25 })
|
||||
this.packageScopedTokens = new TokenPool({ batchSize: 25 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,17 +63,41 @@ class GithubApiProvider {
|
||||
standardTokens: this.standardTokens.serializeDebugInfo({ sanitize }),
|
||||
searchTokens: this.searchTokens.serializeDebugInfo({ sanitize }),
|
||||
graphqlTokens: this.graphqlTokens.serializeDebugInfo({ sanitize }),
|
||||
packageScopedTokens: this.packageScopedTokens.serializeDebugInfo({
|
||||
sanitize,
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
addToken(tokenString) {
|
||||
numReservedScopedTokens() {
|
||||
return this.packageScopedTokens.count()
|
||||
}
|
||||
|
||||
addReservedScopedToken(tokenString, data) {
|
||||
if (!this.withPooling) {
|
||||
throw Error('When not using a token pool, do not provide tokens')
|
||||
}
|
||||
|
||||
const { scopes } = data
|
||||
if (!scopes) {
|
||||
throw new Error('Cannot add unscoped token to reserved token pools')
|
||||
}
|
||||
|
||||
scopes.split('%20').forEach(scope => {
|
||||
if (scope === this.tokenScopeNames.readPackages) {
|
||||
this.packageScopedTokens.add(tokenString, data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
addToken(tokenString, data) {
|
||||
if (this.withPooling) {
|
||||
this.standardTokens.add(tokenString)
|
||||
this.searchTokens.add(tokenString)
|
||||
this.graphqlTokens.add(tokenString)
|
||||
this.standardTokens.add(tokenString, data)
|
||||
this.searchTokens.add(tokenString, data)
|
||||
this.graphqlTokens.add(tokenString, data)
|
||||
} else {
|
||||
throw Error('When not using a token pool, do not provide tokens')
|
||||
}
|
||||
@@ -141,7 +168,11 @@ class GithubApiProvider {
|
||||
this.onTokenInvalidated(token.id)
|
||||
}
|
||||
|
||||
tokenForUrl(url) {
|
||||
tokenForUrl(url, { needsPackageScope }) {
|
||||
if (needsPackageScope) {
|
||||
return this.packageScopedTokens.next()
|
||||
}
|
||||
|
||||
if (url.startsWith('/search')) {
|
||||
return this.searchTokens.next()
|
||||
} else if (url.startsWith('/graphql')) {
|
||||
@@ -154,14 +185,14 @@ class GithubApiProvider {
|
||||
// Act like request(), but tweak headers and query to avoid hitting a rate
|
||||
// limit. Inject `request` so we can pass in `cachingRequest` from
|
||||
// `request-handler.js`.
|
||||
request(request, url, options = {}, callback) {
|
||||
request({ request, url, options = {}, neededScopes = {}, callback }) {
|
||||
const { baseUrl } = this
|
||||
|
||||
let token
|
||||
let tokenString
|
||||
if (this.withPooling) {
|
||||
try {
|
||||
token = this.tokenForUrl(url)
|
||||
token = this.tokenForUrl(url, neededScopes)
|
||||
} catch (e) {
|
||||
callback(e)
|
||||
return
|
||||
@@ -178,7 +209,6 @@ class GithubApiProvider {
|
||||
baseUrl,
|
||||
headers: {
|
||||
'User-Agent': userAgent,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
Authorization: `token ${tokenString}`,
|
||||
...options.headers,
|
||||
},
|
||||
@@ -199,14 +229,20 @@ class GithubApiProvider {
|
||||
})
|
||||
}
|
||||
|
||||
requestAsPromise(request, url, options) {
|
||||
requestAsPromise({ request, url, options, neededScopes }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.request(request, url, options, (err, res, buffer) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve({ res, buffer })
|
||||
}
|
||||
this.request({
|
||||
request,
|
||||
url,
|
||||
options,
|
||||
neededScopes,
|
||||
callback: (err, res, buffer) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve({ res, buffer })
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,7 +6,11 @@ describe('Github API provider', function () {
|
||||
const baseUrl = 'https://github-api.example.com'
|
||||
const reserveFraction = 0.333
|
||||
|
||||
let mockStandardToken, mockSearchToken, mockGraphqlToken, provider
|
||||
let mockStandardToken,
|
||||
mockSearchToken,
|
||||
mockGraphqlToken,
|
||||
mockPackagesScopedToken,
|
||||
provider
|
||||
beforeEach(function () {
|
||||
provider = new GithubApiProvider({ baseUrl, reserveFraction })
|
||||
|
||||
@@ -18,6 +22,11 @@ describe('Github API provider', function () {
|
||||
|
||||
mockGraphqlToken = { update: sinon.spy(), invalidate: sinon.spy() }
|
||||
sinon.stub(provider.graphqlTokens, 'next').returns(mockGraphqlToken)
|
||||
|
||||
mockPackagesScopedToken = { update: sinon.spy(), invalidate: sinon.spy() }
|
||||
sinon
|
||||
.stub(provider.packageScopedTokens, 'next')
|
||||
.returns(mockPackagesScopedToken)
|
||||
})
|
||||
|
||||
context('a search API request', function () {
|
||||
@@ -25,12 +34,16 @@ describe('Github API provider', function () {
|
||||
callback()
|
||||
}
|
||||
it('should obtain an appropriate token', function (done) {
|
||||
provider.request(mockRequest, '/search', {}, (err, res, buffer) => {
|
||||
expect(err).to.be.undefined
|
||||
expect(provider.searchTokens.next).to.have.been.calledOnce
|
||||
expect(provider.standardTokens.next).not.to.have.been.called
|
||||
expect(provider.graphqlTokens.next).not.to.have.been.called
|
||||
done()
|
||||
provider.request({
|
||||
request: mockRequest,
|
||||
url: '/search',
|
||||
callback: (err, res, buffer) => {
|
||||
expect(err).to.be.undefined
|
||||
expect(provider.searchTokens.next).to.have.been.calledOnce
|
||||
expect(provider.standardTokens.next).not.to.have.been.called
|
||||
expect(provider.graphqlTokens.next).not.to.have.been.called
|
||||
done()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -40,12 +53,37 @@ describe('Github API provider', function () {
|
||||
callback()
|
||||
}
|
||||
it('should obtain an appropriate token', function (done) {
|
||||
provider.request(mockRequest, '/graphql', {}, (err, res, buffer) => {
|
||||
expect(err).to.be.undefined
|
||||
expect(provider.searchTokens.next).not.to.have.been.called
|
||||
expect(provider.standardTokens.next).not.to.have.been.called
|
||||
expect(provider.graphqlTokens.next).to.have.been.calledOnce
|
||||
done()
|
||||
provider.request({
|
||||
request: mockRequest,
|
||||
url: '/graphql',
|
||||
callback: (err, res, buffer) => {
|
||||
expect(err).to.be.undefined
|
||||
expect(provider.searchTokens.next).not.to.have.been.called
|
||||
expect(provider.standardTokens.next).not.to.have.been.called
|
||||
expect(provider.graphqlTokens.next).to.have.been.calledOnce
|
||||
done()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
context('a request requiring the read:packages scope', function () {
|
||||
const mockRequest = (options, callback) => {
|
||||
callback()
|
||||
}
|
||||
it('should obtain an appropriate token', function (done) {
|
||||
provider.request({
|
||||
request: mockRequest,
|
||||
url: '/graphql',
|
||||
neededScopes: { needsPackageScope: true },
|
||||
callback: (err, res, buffer) => {
|
||||
expect(err).to.be.undefined
|
||||
expect(provider.searchTokens.next).not.to.have.been.called
|
||||
expect(provider.standardTokens.next).not.to.have.been.called
|
||||
expect(provider.graphqlTokens.next).not.to.have.been.called
|
||||
expect(provider.packageScopedTokens.next).to.have.been.calledOnce
|
||||
done()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -55,12 +93,16 @@ describe('Github API provider', function () {
|
||||
callback()
|
||||
}
|
||||
it('should obtain an appropriate token', function (done) {
|
||||
provider.request(mockRequest, '/repo', {}, (err, res, buffer) => {
|
||||
expect(err).to.be.undefined
|
||||
expect(provider.searchTokens.next).not.to.have.been.called
|
||||
expect(provider.standardTokens.next).to.have.been.calledOnce
|
||||
expect(provider.graphqlTokens.next).not.to.have.been.called
|
||||
done()
|
||||
provider.request({
|
||||
request: mockRequest,
|
||||
url: '/repo',
|
||||
callback: (err, res, buffer) => {
|
||||
expect(err).to.be.undefined
|
||||
expect(provider.searchTokens.next).not.to.have.been.called
|
||||
expect(provider.standardTokens.next).to.have.been.calledOnce
|
||||
expect(provider.graphqlTokens.next).not.to.have.been.called
|
||||
done()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -84,25 +126,33 @@ describe('Github API provider', function () {
|
||||
}
|
||||
|
||||
it('should invoke the callback', function (done) {
|
||||
provider.request(mockRequest, '/foo', {}, (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
expect(Object.is(res, mockResponse)).to.be.true
|
||||
expect(Object.is(buffer, mockBuffer)).to.be.true
|
||||
done()
|
||||
provider.request({
|
||||
request: mockRequest,
|
||||
url: '/foo',
|
||||
callback: (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
expect(Object.is(res, mockResponse)).to.be.true
|
||||
expect(Object.is(buffer, mockBuffer)).to.be.true
|
||||
done()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should update the token with the expected values', function (done) {
|
||||
provider.request(mockRequest, '/foo', {}, (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
const expectedUsesRemaining =
|
||||
remaining - Math.ceil(reserveFraction * rateLimit)
|
||||
expect(mockStandardToken.update).to.have.been.calledWith(
|
||||
expectedUsesRemaining,
|
||||
nextReset
|
||||
)
|
||||
expect(mockStandardToken.invalidate).not.to.have.been.called
|
||||
done()
|
||||
provider.request({
|
||||
request: mockRequest,
|
||||
url: '/foo',
|
||||
callback: (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
const expectedUsesRemaining =
|
||||
remaining - Math.ceil(reserveFraction * rateLimit)
|
||||
expect(mockStandardToken.update).to.have.been.calledWith(
|
||||
expectedUsesRemaining,
|
||||
nextReset
|
||||
)
|
||||
expect(mockStandardToken.invalidate).not.to.have.been.called
|
||||
done()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -132,25 +182,33 @@ describe('Github API provider', function () {
|
||||
}
|
||||
|
||||
it('should invoke the callback', function (done) {
|
||||
provider.request(mockRequest, '/graphql', {}, (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
expect(Object.is(res, mockResponse)).to.be.true
|
||||
expect(Object.is(buffer, mockBuffer)).to.be.true
|
||||
done()
|
||||
provider.request({
|
||||
request: mockRequest,
|
||||
url: '/graphql',
|
||||
callback: (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
expect(Object.is(res, mockResponse)).to.be.true
|
||||
expect(Object.is(buffer, mockBuffer)).to.be.true
|
||||
done()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should update the token with the expected values', function (done) {
|
||||
provider.request(mockRequest, '/graphql', {}, (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
const expectedUsesRemaining =
|
||||
remaining - Math.ceil(reserveFraction * rateLimit)
|
||||
expect(mockGraphqlToken.update).to.have.been.calledWith(
|
||||
expectedUsesRemaining,
|
||||
nextReset
|
||||
)
|
||||
expect(mockGraphqlToken.invalidate).not.to.have.been.called
|
||||
done()
|
||||
provider.request({
|
||||
request: mockRequest,
|
||||
url: '/graphql',
|
||||
callback: (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
const expectedUsesRemaining =
|
||||
remaining - Math.ceil(reserveFraction * rateLimit)
|
||||
expect(mockGraphqlToken.update).to.have.been.calledWith(
|
||||
expectedUsesRemaining,
|
||||
nextReset
|
||||
)
|
||||
expect(mockGraphqlToken.invalidate).not.to.have.been.called
|
||||
done()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -164,11 +222,15 @@ describe('Github API provider', function () {
|
||||
}
|
||||
|
||||
it('should invoke the callback and update the token with the expected values', function (done) {
|
||||
provider.request(mockRequest, '/foo', {}, (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
expect(mockStandardToken.invalidate).to.have.been.calledOnce
|
||||
expect(mockStandardToken.update).not.to.have.been.called
|
||||
done()
|
||||
provider.request({
|
||||
request: mockRequest,
|
||||
url: '/foo',
|
||||
callback: (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
expect(mockStandardToken.invalidate).to.have.been.calledOnce
|
||||
expect(mockStandardToken.update).not.to.have.been.called
|
||||
done()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -180,10 +242,14 @@ describe('Github API provider', function () {
|
||||
}
|
||||
|
||||
it('should pass the error to the callback', function (done) {
|
||||
provider.request(mockRequest, '/foo', {}, (err, res, buffer) => {
|
||||
expect(err).to.be.an.instanceof(Error)
|
||||
expect(err.message).to.equal('connection timeout')
|
||||
done()
|
||||
provider.request({
|
||||
request: mockRequest,
|
||||
url: '/foo',
|
||||
callback: (err, res, buffer) => {
|
||||
expect(err).to.be.an.instanceof(Error)
|
||||
expect(err.message).to.equal('connection timeout')
|
||||
done()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,21 +2,22 @@ import gql from 'graphql-tag'
|
||||
import { mergeQueries } from '../../core/base-service/graphql.js'
|
||||
import { BaseGraphqlService, BaseJsonService } from '../index.js'
|
||||
|
||||
function createRequestFetcher(context, config) {
|
||||
function createRequestFetcher(context, config, neededScopes) {
|
||||
const { sendAndCacheRequestWithCallbacks, githubApiProvider } = context
|
||||
|
||||
return async (url, options) =>
|
||||
githubApiProvider.requestAsPromise(
|
||||
sendAndCacheRequestWithCallbacks,
|
||||
githubApiProvider.requestAsPromise({
|
||||
request: sendAndCacheRequestWithCallbacks,
|
||||
url,
|
||||
options
|
||||
)
|
||||
options,
|
||||
neededScopes,
|
||||
})
|
||||
}
|
||||
|
||||
class GithubAuthV3Service extends BaseJsonService {
|
||||
constructor(context, config) {
|
||||
constructor(context, config, neededScopes) {
|
||||
super(context, config)
|
||||
this._requestFetcher = createRequestFetcher(context, config)
|
||||
this._requestFetcher = createRequestFetcher(context, config, neededScopes)
|
||||
this.staticAuthConfigured = true
|
||||
}
|
||||
}
|
||||
@@ -27,10 +28,10 @@ class GithubAuthV3Service extends BaseJsonService {
|
||||
// useful when consuming GitHub endpoints which are not rate-limited: it
|
||||
// avoids wasting API quota on them in production.
|
||||
class ConditionalGithubAuthV3Service extends BaseJsonService {
|
||||
constructor(context, config) {
|
||||
constructor(context, config, neededScopes) {
|
||||
super(context, config)
|
||||
if (context.githubApiProvider.globalToken) {
|
||||
this._requestFetcher = createRequestFetcher(context, config)
|
||||
this._requestFetcher = createRequestFetcher(context, config, neededScopes)
|
||||
this.staticAuthConfigured = true
|
||||
} else {
|
||||
this.staticAuthConfigured = false
|
||||
@@ -39,9 +40,9 @@ class ConditionalGithubAuthV3Service extends BaseJsonService {
|
||||
}
|
||||
|
||||
class GithubAuthV4Service extends BaseGraphqlService {
|
||||
constructor(context, config) {
|
||||
constructor(context, config, neededScopes) {
|
||||
super(context, config)
|
||||
this._requestFetcher = createRequestFetcher(context, config)
|
||||
this._requestFetcher = createRequestFetcher(context, config, neededScopes)
|
||||
this.staticAuthConfigured = true
|
||||
}
|
||||
|
||||
|
||||
90
services/github/github-auth-service.spec.js
Normal file
90
services/github/github-auth-service.spec.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import Joi from 'joi'
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import { GithubAuthV3Service } from './github-auth-service.js'
|
||||
import GithubApiProvider from './github-api-provider.js'
|
||||
|
||||
describe('GithubAuthV3Service', function () {
|
||||
class DummyGithubAuthV3Service extends GithubAuthV3Service {
|
||||
static category = 'build'
|
||||
static route = { base: 'runs' }
|
||||
|
||||
async handle() {
|
||||
const { requiredString } = await this._requestJson({
|
||||
schema: Joi.object({
|
||||
requiredString: Joi.string().required(),
|
||||
}).required(),
|
||||
url: 'https://github-api.example.com/repos/badges/shields/check-runs',
|
||||
options: {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.antiope-preview+json',
|
||||
},
|
||||
},
|
||||
})
|
||||
return { message: requiredString }
|
||||
}
|
||||
}
|
||||
|
||||
class ScopedDummyGithubAuthV3Service extends DummyGithubAuthV3Service {
|
||||
constructor(context, config) {
|
||||
super(context, config, { needsPackageScope: true })
|
||||
}
|
||||
}
|
||||
|
||||
let sendAndCacheRequestWithCallbacks, mockToken
|
||||
const githubApiProvider = new GithubApiProvider({
|
||||
baseUrl: 'https://github-api.example.com',
|
||||
})
|
||||
|
||||
beforeEach(function () {
|
||||
sendAndCacheRequestWithCallbacks = sinon.stub().returns(
|
||||
Promise.resolve({
|
||||
buffer: '{"requiredString": "some-string"}',
|
||||
res: { statusCode: 200 },
|
||||
})
|
||||
)
|
||||
mockToken = { id: 'abc123', update: sinon.mock(), invalidate: sinon.mock() }
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it('forwards custom Accept header', async function () {
|
||||
sinon.stub(githubApiProvider.standardTokens, 'next').returns(mockToken)
|
||||
|
||||
DummyGithubAuthV3Service.invoke({
|
||||
sendAndCacheRequestWithCallbacks,
|
||||
githubApiProvider,
|
||||
})
|
||||
|
||||
expect(sendAndCacheRequestWithCallbacks).to.have.been.calledOnceWith({
|
||||
headers: {
|
||||
'User-Agent': 'Shields.io/2003a',
|
||||
Accept: 'application/vnd.github.antiope-preview+json',
|
||||
Authorization: 'token abc123',
|
||||
},
|
||||
url: 'https://github-api.example.com/repos/badges/shields/check-runs',
|
||||
baseUrl: 'https://github-api.example.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses token with correct read scope', function () {
|
||||
sinon.stub(githubApiProvider.packageScopedTokens, 'next').returns(mockToken)
|
||||
|
||||
ScopedDummyGithubAuthV3Service.invoke({
|
||||
sendAndCacheRequestWithCallbacks,
|
||||
githubApiProvider,
|
||||
})
|
||||
|
||||
expect(sendAndCacheRequestWithCallbacks).to.have.been.calledOnceWith({
|
||||
headers: {
|
||||
'User-Agent': 'Shields.io/2003a',
|
||||
Accept: 'application/vnd.github.antiope-preview+json',
|
||||
Authorization: 'token abc123',
|
||||
},
|
||||
url: 'https://github-api.example.com/repos/badges/shields/check-runs',
|
||||
baseUrl: 'https://github-api.example.com',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,11 @@ import GithubApiProvider from './github-api-provider.js'
|
||||
import { setRoutes as setAdminRoutes } from './auth/admin.js'
|
||||
import { setRoutes as setAcceptorRoutes } from './auth/acceptor.js'
|
||||
|
||||
const readPackagesScope = 'read:packages'
|
||||
// Multiple scopes need to be uri-encoded space delimited
|
||||
const tokenScopes = `${readPackagesScope}`
|
||||
const persistenceScopeDelimiter = '.scopes.'
|
||||
|
||||
// Convenience class with all the stuff related to the Github API and its
|
||||
// authorization tokens, to simplify server initialization.
|
||||
class GithubConstellation {
|
||||
@@ -24,6 +29,8 @@ class GithubConstellation {
|
||||
this._debugEnabled = config.service.debug.enabled
|
||||
this._debugIntervalSeconds = config.service.debug.intervalSeconds
|
||||
this.shieldsSecret = config.private.shields_secret
|
||||
this._tokenScopes = {}
|
||||
this._maxNumReservedScopedTokens = 0
|
||||
|
||||
const { redis_url: redisUrl, gh_token: globalToken } = config.private
|
||||
if (redisUrl) {
|
||||
@@ -38,6 +45,9 @@ class GithubConstellation {
|
||||
baseUrl: process.env.GITHUB_URL || 'https://api.github.com',
|
||||
globalToken,
|
||||
withPooling: !globalToken,
|
||||
tokenScopeNames: {
|
||||
readPackages: readPackagesScope,
|
||||
},
|
||||
onTokenInvalidated: tokenString => this.onTokenInvalidated(tokenString),
|
||||
})
|
||||
|
||||
@@ -70,8 +80,21 @@ class GithubConstellation {
|
||||
log.error(e)
|
||||
}
|
||||
|
||||
// Reserve a subset of scoped tokens from the total set
|
||||
// to be used for queries which require an explicit scope,
|
||||
// while leaving a sufficient amount of tokens (scoped or unscoped)
|
||||
// for the bulk of our requests which don't care about scopes.
|
||||
this._maxNumReservedScopedTokens = Math.floor(tokens.length * 0.15)
|
||||
tokens.forEach(tokenString => {
|
||||
this.apiProvider.addToken(tokenString)
|
||||
const [token, scopes] = tokenString.split(persistenceScopeDelimiter)
|
||||
this._tokenScopes[token] = scopes || null
|
||||
const data = { scopes }
|
||||
const numReserved = this.apiProvider.numReservedScopedTokens()
|
||||
if (scopes && numReserved < this._maxNumReservedScopedTokens) {
|
||||
this.apiProvider.addReservedScopedToken(token, data)
|
||||
} else {
|
||||
this.apiProvider.addToken(token, data)
|
||||
}
|
||||
})
|
||||
|
||||
const { shieldsSecret, apiProvider } = this
|
||||
@@ -81,19 +104,53 @@ class GithubConstellation {
|
||||
setAcceptorRoutes({
|
||||
server,
|
||||
authHelper: this.oauthHelper,
|
||||
onTokenAccepted: tokenString => this.onTokenAdded(tokenString),
|
||||
tokenScopes,
|
||||
onTokenAccepted: tokenString =>
|
||||
this.onTokenAdded(tokenString, tokenScopes),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onTokenAdded(tokenString) {
|
||||
onTokenAdded(tokenString, tokenScopes) {
|
||||
if (!this.persistence) {
|
||||
throw Error('Token persistence is not configured')
|
||||
}
|
||||
this.apiProvider.addToken(tokenString)
|
||||
const data = { scopes: tokenScopes }
|
||||
const numReserved = this.apiProvider.numReservedScopedTokens()
|
||||
if (numReserved < this._maxNumReservedScopedTokens) {
|
||||
this.apiProvider.addReservedScopedToken(tokenString, data)
|
||||
} else {
|
||||
this.apiProvider.addToken(tokenString, data)
|
||||
}
|
||||
|
||||
process.nextTick(async () => {
|
||||
try {
|
||||
await this.persistence.noteTokenAdded(tokenString)
|
||||
// To avoid having multiple set entries for re-authorized/re-scoped
|
||||
// tokens we need to first remove the previous entry that had different scopes
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(this._tokenScopes, tokenString)
|
||||
) {
|
||||
const currentScopes = this._tokenScopes[tokenString]
|
||||
// These scopes shouldn't match in practice, as that would
|
||||
// indicate the function has somehow been invoked with an existing
|
||||
// token but without any scope changes. Nevertheless, the conditional
|
||||
// guard is here in case there are circumstances that assumption fails
|
||||
// to be upheld.
|
||||
if (currentScopes !== tokenScopes) {
|
||||
const token = currentScopes
|
||||
? `${tokenString}${persistenceScopeDelimiter}${currentScopes}`
|
||||
: tokenString
|
||||
await this.persistence.noteTokenRemoved(token)
|
||||
}
|
||||
}
|
||||
// It's unlikely that we'd evert revert back to no longer requesting any scopes
|
||||
// but handling that scenario regardless so we don't end up
|
||||
// with junk like `abc123.scopes.undefined` in redis
|
||||
const token = tokenScopes
|
||||
? `${tokenString}${persistenceScopeDelimiter}${tokenScopes}`
|
||||
: tokenString
|
||||
await this.persistence.noteTokenAdded(token)
|
||||
this._tokenScopes[tokenString] = tokenScopes || null
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
}
|
||||
@@ -104,7 +161,12 @@ class GithubConstellation {
|
||||
if (this.persistence) {
|
||||
process.nextTick(async () => {
|
||||
try {
|
||||
await this.persistence.noteTokenRemoved(tokenString)
|
||||
const scopes = this._tokenScopes[tokenString]
|
||||
const token = scopes
|
||||
? `${tokenString}${persistenceScopeDelimiter}${scopes}`
|
||||
: tokenString
|
||||
await this.persistence.noteTokenRemoved(token)
|
||||
delete this._tokenScopes[tokenString]
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
}
|
||||
|
||||
208
services/github/github-constellation.spec.js
Normal file
208
services/github/github-constellation.spec.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import log from '../../core/server/log.js'
|
||||
import RedisTokenPersistence from '../../core/token-pooling/redis-token-persistence.js'
|
||||
import GithubConstellation from './github-constellation.js'
|
||||
import GithubApiProvider from './github-api-provider.js'
|
||||
|
||||
describe('GithubConstellation', function () {
|
||||
const tokens = [
|
||||
'abc123',
|
||||
'def4567.scopes.read:packages%20read:user',
|
||||
'def789.scopes.read:packages',
|
||||
'ghi012',
|
||||
'fff444.scopes.read:packages',
|
||||
'555eee.scopes.read:packages',
|
||||
'ddd666',
|
||||
'777ccc',
|
||||
'bbb888',
|
||||
'999aaa',
|
||||
'000111.scopes.read:packages',
|
||||
'222333.scopes.read:packages',
|
||||
'111111',
|
||||
'888888',
|
||||
]
|
||||
const config = {
|
||||
private: {
|
||||
redis_url: 'localhost',
|
||||
},
|
||||
service: {
|
||||
debug: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
const server = { ajax: { on: sinon.stub() } }
|
||||
|
||||
beforeEach(function () {
|
||||
sinon.stub(log, 'log')
|
||||
sinon
|
||||
.stub(GithubConstellation, '_createOauthHelper')
|
||||
.returns({ isConfigured: false })
|
||||
sinon.stub(GithubConstellation.prototype, 'scheduleDebugLogging')
|
||||
sinon.stub(RedisTokenPersistence.prototype, 'initialize').returns(tokens)
|
||||
sinon.stub(RedisTokenPersistence.prototype, 'noteTokenAdded')
|
||||
sinon.stub(RedisTokenPersistence.prototype, 'noteTokenRemoved')
|
||||
sinon.spy(GithubApiProvider.prototype, 'addToken')
|
||||
sinon.spy(GithubApiProvider.prototype, 'addReservedScopedToken')
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
context('initialize', function () {
|
||||
it('does not fetch tokens when pooling disabled', async function () {
|
||||
const constellation = new GithubConstellation({
|
||||
...config,
|
||||
...{ private: { gh_token: 'secret' } },
|
||||
})
|
||||
await constellation.initialize(server)
|
||||
expect(RedisTokenPersistence.prototype.initialize).not.to.have.been.called
|
||||
})
|
||||
|
||||
it('loads both scoped and unscoped tokens', async function () {
|
||||
const constellation = new GithubConstellation(config)
|
||||
await constellation.initialize(server)
|
||||
expect(constellation.apiProvider.graphqlTokens.count()).to.equal(12)
|
||||
expect(constellation.apiProvider.searchTokens.count()).to.equal(12)
|
||||
expect(constellation.apiProvider.standardTokens.count()).to.equal(12)
|
||||
expect(constellation.apiProvider.packageScopedTokens.count()).to.equal(2)
|
||||
expect(
|
||||
GithubApiProvider.prototype.addReservedScopedToken
|
||||
).to.be.calledWithExactly('def4567', {
|
||||
scopes: 'read:packages%20read:user',
|
||||
})
|
||||
expect(
|
||||
GithubApiProvider.prototype.addReservedScopedToken
|
||||
).to.be.calledWithExactly('def789', {
|
||||
scopes: 'read:packages',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
context('onTokenAdded', function () {
|
||||
it('adds new scoped token with met reserves', async function () {
|
||||
const token = 'shh_secret'
|
||||
sinon
|
||||
.stub(GithubApiProvider.prototype, 'numReservedScopedTokens')
|
||||
.returns(2)
|
||||
const clock = sinon.useFakeTimers()
|
||||
const constellation = new GithubConstellation(config)
|
||||
await constellation.initialize(server)
|
||||
constellation._maxNumReservedScopedTokens = 2
|
||||
constellation.onTokenAdded(token, 'read:packages')
|
||||
await clock.tickAsync()
|
||||
expect(GithubApiProvider.prototype.addToken).to.be.calledWithExactly(
|
||||
token,
|
||||
{ scopes: 'read:packages' }
|
||||
)
|
||||
expect(
|
||||
GithubApiProvider.prototype.addReservedScopedToken
|
||||
).to.not.be.calledWith(token)
|
||||
expect(RedisTokenPersistence.prototype.noteTokenAdded).to.be.calledWith(
|
||||
`${token}.scopes.read:packages`
|
||||
)
|
||||
expect(RedisTokenPersistence.prototype.noteTokenRemoved).to.not.be.called
|
||||
expect(Object.keys(constellation._tokenScopes).length).to.equal(15)
|
||||
expect(constellation._tokenScopes[token]).to.equal('read:packages')
|
||||
})
|
||||
|
||||
it('adds new scoped token with unmet reserves', async function () {
|
||||
const token = 'shh_secret'
|
||||
sinon
|
||||
.stub(GithubApiProvider.prototype, 'numReservedScopedTokens')
|
||||
.returns(2)
|
||||
const clock = sinon.useFakeTimers()
|
||||
const constellation = new GithubConstellation(config)
|
||||
await constellation.initialize(server)
|
||||
constellation._maxNumReservedScopedTokens = 3
|
||||
constellation.onTokenAdded(token, 'read:packages')
|
||||
await clock.tickAsync()
|
||||
expect(
|
||||
GithubApiProvider.prototype.addReservedScopedToken
|
||||
).to.be.calledWithExactly(token, { scopes: 'read:packages' })
|
||||
expect(GithubApiProvider.prototype.addToken).to.not.be.calledWith(token)
|
||||
expect(RedisTokenPersistence.prototype.noteTokenAdded).to.be.calledWith(
|
||||
`${token}.scopes.read:packages`
|
||||
)
|
||||
expect(RedisTokenPersistence.prototype.noteTokenRemoved).to.not.be.called
|
||||
expect(Object.keys(constellation._tokenScopes).length).to.equal(15)
|
||||
expect(constellation._tokenScopes[token]).to.equal('read:packages')
|
||||
})
|
||||
|
||||
it('adds new unscoped token', async function () {
|
||||
const token = '1234567890987654321'
|
||||
const clock = sinon.useFakeTimers()
|
||||
const constellation = new GithubConstellation(config)
|
||||
await constellation.initialize(server)
|
||||
constellation.onTokenAdded(token)
|
||||
await clock.tickAsync()
|
||||
expect(GithubApiProvider.prototype.addToken).to.be.calledWithExactly(
|
||||
token,
|
||||
{ scopes: undefined }
|
||||
)
|
||||
expect(
|
||||
GithubApiProvider.prototype.addReservedScopedToken
|
||||
).to.not.be.calledWith(token)
|
||||
expect(RedisTokenPersistence.prototype.noteTokenAdded).to.be.calledWith(
|
||||
token
|
||||
)
|
||||
expect(RedisTokenPersistence.prototype.noteTokenRemoved).to.not.be.called
|
||||
expect(Object.keys(constellation._tokenScopes).length).to.equal(15)
|
||||
expect(constellation._tokenScopes[token]).to.equal(null)
|
||||
})
|
||||
|
||||
it('updates scopes on existing token', async function () {
|
||||
const existingToken = 'abc123'
|
||||
const clock = sinon.useFakeTimers()
|
||||
const constellation = new GithubConstellation(config)
|
||||
await constellation.initialize(server)
|
||||
sinon
|
||||
.stub(GithubApiProvider.prototype, 'numReservedScopedTokens')
|
||||
.returns(1)
|
||||
constellation.onTokenAdded(existingToken, 'read:packages')
|
||||
await clock.tickAsync()
|
||||
expect(
|
||||
GithubApiProvider.prototype.addReservedScopedToken
|
||||
).to.be.calledWithExactly(existingToken, { scopes: 'read:packages' })
|
||||
expect(GithubApiProvider.prototype.addToken.callCount).to.equal(12)
|
||||
expect(RedisTokenPersistence.prototype.noteTokenAdded).to.be.calledWith(
|
||||
`${existingToken}.scopes.read:packages`
|
||||
)
|
||||
expect(RedisTokenPersistence.prototype.noteTokenRemoved).to.be.calledWith(
|
||||
existingToken
|
||||
)
|
||||
expect(Object.keys(constellation._tokenScopes).length).to.equal(14)
|
||||
expect(constellation._tokenScopes[existingToken]).to.equal(
|
||||
'read:packages'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
context('onTokenInvalidated', function () {
|
||||
it('removes scoped token', async function () {
|
||||
const clock = sinon.useFakeTimers()
|
||||
const constellation = new GithubConstellation(config)
|
||||
await constellation.initialize(server)
|
||||
constellation.onTokenInvalidated('def789')
|
||||
await clock.tickAsync()
|
||||
expect(RedisTokenPersistence.prototype.noteTokenRemoved).to.be.calledWith(
|
||||
'def789.scopes.read:packages'
|
||||
)
|
||||
expect(Object.keys(constellation._tokenScopes).length).to.equal(13)
|
||||
})
|
||||
|
||||
it('removes unscoped token', async function () {
|
||||
const clock = sinon.useFakeTimers()
|
||||
const constellation = new GithubConstellation(config)
|
||||
await constellation.initialize(server)
|
||||
constellation.onTokenInvalidated('888888')
|
||||
await clock.tickAsync()
|
||||
expect(
|
||||
RedisTokenPersistence.prototype.noteTokenRemoved
|
||||
).to.be.calledWithExactly('888888')
|
||||
expect(Object.keys(constellation._tokenScopes).length).to.equal(13)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -14,12 +14,10 @@ t.create('Lerna version (independent)')
|
||||
message: 'independent',
|
||||
})
|
||||
|
||||
t.create('Lerna version (branch)')
|
||||
.get('/facebook/jest/master.json')
|
||||
.expectBadge({
|
||||
label: 'lerna@master',
|
||||
message: isSemver,
|
||||
})
|
||||
t.create('Lerna version (branch)').get('/facebook/jest/main.json').expectBadge({
|
||||
label: 'lerna@main',
|
||||
message: isSemver,
|
||||
})
|
||||
|
||||
t.create('Lerna version (lerna.json missing)')
|
||||
.get('/PyvesB/empty-repo.json')
|
||||
|
||||
19
services/gitlab/gitlab-base.js
Normal file
19
services/gitlab/gitlab-base.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { BaseJsonService } from '../index.js'
|
||||
|
||||
export default class GitLabBase extends BaseJsonService {
|
||||
static auth = {
|
||||
passKey: 'gitlab_token',
|
||||
serviceKey: 'gitlab',
|
||||
}
|
||||
|
||||
async fetch({ url, options, schema, errorMessages }) {
|
||||
return this._requestJson(
|
||||
this.authHelper.withBasicAuth({
|
||||
schema,
|
||||
url,
|
||||
options,
|
||||
errorMessages,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
131
services/gitlab/gitlab-tag.service.js
Normal file
131
services/gitlab/gitlab-tag.service.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import Joi from 'joi'
|
||||
import { version as versionColor } from '../color-formatters.js'
|
||||
import { optionalUrl } from '../validators.js'
|
||||
import { latest } from '../version.js'
|
||||
import { addv } from '../text-formatters.js'
|
||||
import { NotFound } from '../index.js'
|
||||
import GitLabBase from './gitlab-base.js'
|
||||
|
||||
const schema = Joi.array().items(
|
||||
Joi.object({
|
||||
name: Joi.string().required(),
|
||||
})
|
||||
)
|
||||
|
||||
const queryParamSchema = Joi.object({
|
||||
gitlab_url: optionalUrl,
|
||||
include_prereleases: Joi.equal(''),
|
||||
sort: Joi.string().valid('date', 'semver').default('date'),
|
||||
}).required()
|
||||
|
||||
export default class GitlabTag extends GitLabBase {
|
||||
static category = 'version'
|
||||
|
||||
static route = {
|
||||
base: 'gitlab/v/tag',
|
||||
pattern: ':user/:repo',
|
||||
queryParamSchema,
|
||||
}
|
||||
|
||||
static examples = [
|
||||
{
|
||||
title: 'GitLab tag (latest by date)',
|
||||
namedParams: {
|
||||
user: 'shields-ops-group',
|
||||
repo: 'tag-test',
|
||||
},
|
||||
queryParams: { sort: 'date' },
|
||||
staticPreview: this.render({ version: 'v2.0.0' }),
|
||||
},
|
||||
{
|
||||
title: 'GitLab tag (latest by SemVer)',
|
||||
namedParams: {
|
||||
user: 'shields-ops-group',
|
||||
repo: 'tag-test',
|
||||
},
|
||||
queryParams: { sort: 'semver' },
|
||||
staticPreview: this.render({ version: 'v4.0.0' }),
|
||||
},
|
||||
{
|
||||
title: 'GitLab tag (latest by SemVer pre-release)',
|
||||
namedParams: {
|
||||
user: 'shields-ops-group',
|
||||
repo: 'tag-test',
|
||||
},
|
||||
queryParams: {
|
||||
sort: 'semver',
|
||||
include_prereleases: null,
|
||||
},
|
||||
staticPreview: this.render({ version: 'v5.0.0-beta.1', sort: 'semver' }),
|
||||
},
|
||||
{
|
||||
title: 'GitLab tag (custom instance)',
|
||||
namedParams: {
|
||||
user: 'GNOME',
|
||||
repo: 'librsvg',
|
||||
},
|
||||
queryParams: {
|
||||
sort: 'semver',
|
||||
include_prereleases: null,
|
||||
gitlab_url: 'https://gitlab.gnome.org',
|
||||
},
|
||||
staticPreview: this.render({ version: 'v2.51.4' }),
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'tag' }
|
||||
|
||||
static render({ version, sort }) {
|
||||
return {
|
||||
message: addv(version),
|
||||
color: sort === 'semver' ? versionColor(version) : 'blue',
|
||||
}
|
||||
}
|
||||
|
||||
async fetch({ user, repo, baseUrl }) {
|
||||
// https://docs.gitlab.com/ee/api/tags.html
|
||||
// N.B. the documentation has contradictory information about default sort order.
|
||||
// As of 2020-10-11 the default is by date, but we add the `order_by` query param
|
||||
// explicitly in case that changes upstream.
|
||||
return super.fetch({
|
||||
schema,
|
||||
url: `${baseUrl}/api/v4/projects/${user}%2F${repo}/repository/tags`,
|
||||
options: { qs: { order_by: 'updated' } },
|
||||
errorMessages: {
|
||||
404: 'repo not found',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
static transform({ tags, sort, includePrereleases }) {
|
||||
if (tags.length === 0) {
|
||||
throw new NotFound({ prettyMessage: 'no tags found' })
|
||||
}
|
||||
|
||||
if (sort === 'date') {
|
||||
return tags[0].name
|
||||
}
|
||||
|
||||
return latest(
|
||||
tags.map(t => t.name),
|
||||
{ pre: includePrereleases }
|
||||
)
|
||||
}
|
||||
|
||||
async handle(
|
||||
{ user, repo },
|
||||
{
|
||||
gitlab_url: baseUrl = 'https://gitlab.com',
|
||||
include_prereleases: pre,
|
||||
sort,
|
||||
}
|
||||
) {
|
||||
const tags = await this.fetch({ user, repo, baseUrl })
|
||||
const version = this.constructor.transform({
|
||||
tags,
|
||||
sort,
|
||||
includePrereleases: pre !== undefined,
|
||||
})
|
||||
return this.constructor.render({ version, sort })
|
||||
}
|
||||
}
|
||||
47
services/gitlab/gitlab-tag.spec.js
Normal file
47
services/gitlab/gitlab-tag.spec.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { expect } from 'chai'
|
||||
import nock from 'nock'
|
||||
import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js'
|
||||
import GitLabTag from './gitlab-tag.service.js'
|
||||
|
||||
describe('GitLabTag', function () {
|
||||
describe('auth', function () {
|
||||
cleanUpNockAfterEach()
|
||||
|
||||
const fakeToken = 'abc123'
|
||||
const config = {
|
||||
public: {
|
||||
services: {
|
||||
gitlab: {
|
||||
authorizedOrigins: ['https://gitlab.com'],
|
||||
},
|
||||
},
|
||||
},
|
||||
private: {
|
||||
gitlab_token: fakeToken,
|
||||
},
|
||||
}
|
||||
|
||||
it('sends the auth information as configured', async function () {
|
||||
const scope = nock('https://gitlab.com/')
|
||||
.get('/api/v4/projects/foo%2Fbar/repository/tags?order_by=updated')
|
||||
// This ensures that the expected credentials are actually being sent with the HTTP request.
|
||||
// Without this the request wouldn't match and the test would fail.
|
||||
.basicAuth({ user: '', pass: fakeToken })
|
||||
.reply(200, [{ name: '1.9' }])
|
||||
|
||||
expect(
|
||||
await GitLabTag.invoke(
|
||||
defaultContext,
|
||||
config,
|
||||
{ user: 'foo', repo: 'bar' },
|
||||
{}
|
||||
)
|
||||
).to.deep.equal({
|
||||
message: 'v1.9',
|
||||
color: 'blue',
|
||||
})
|
||||
|
||||
scope.done()
|
||||
})
|
||||
})
|
||||
})
|
||||
27
services/gitlab/gitlab-tag.tester.js
Normal file
27
services/gitlab/gitlab-tag.tester.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { isSemver } from '../test-validators.js'
|
||||
import { createServiceTester } from '../tester.js'
|
||||
export const t = await createServiceTester()
|
||||
|
||||
t.create('Tag (latest by date)')
|
||||
.get('/shields-ops-group/tag-test.json')
|
||||
.expectBadge({ label: 'tag', message: 'v2.0.0', color: 'blue' })
|
||||
|
||||
t.create('Tag (latest by SemVer)')
|
||||
.get('/shields-ops-group/tag-test.json?sort=semver')
|
||||
.expectBadge({ label: 'tag', message: 'v4.0.0', color: 'blue' })
|
||||
|
||||
t.create('Tag (latest by SemVer pre-release)')
|
||||
.get('/shields-ops-group/tag-test.json?sort=semver&include_prereleases')
|
||||
.expectBadge({ label: 'tag', message: 'v5.0.0-beta.1', color: 'orange' })
|
||||
|
||||
t.create('Tag (custom instance')
|
||||
.get('/GNOME/librsvg.json?gitlab_url=https://gitlab.gnome.org')
|
||||
.expectBadge({ label: 'tag', message: isSemver, color: 'blue' })
|
||||
|
||||
t.create('Tag (repo not found)')
|
||||
.get('/fdroid/nonexistant.json')
|
||||
.expectBadge({ label: 'tag', message: 'repo not found' })
|
||||
|
||||
t.create('Tag (no tags)')
|
||||
.get('/fdroid/fdroiddata.json')
|
||||
.expectBadge({ label: 'tag', message: 'no tags found' })
|
||||
@@ -30,10 +30,10 @@ const documentation = `
|
||||
<p>
|
||||
In order for this badge to work, the host of your room must allow guest accounts or dummy accounts to register, and the room must be world readable (chat history visible to anyone).
|
||||
</br>
|
||||
The following steps will show you how to setup the badge URL using the Riot.im Matrix client.
|
||||
The following steps will show you how to setup the badge URL using the Element Matrix client.
|
||||
</br>
|
||||
<ul>
|
||||
<li>Select the desired room inside the Riot.im client</li>
|
||||
<li>Select the desired room inside the Element client</li>
|
||||
<li>Click on the room settings button (gear icon) located near the top right of the client</li>
|
||||
<li>Scroll to the very bottom of the settings page and look under the <code>Addresses</code> section</li>
|
||||
<li>You should see one or more <code>room addresses (or aliases)</code>, which can be easily identified with their starting hash (<code>#</code>) character (ex: <code>#twim:matrix.org</code>)</li>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import Joi from 'joi'
|
||||
import { nonNegativeInteger } from '../validators.js'
|
||||
import { anyInteger } from '../validators.js'
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { BaseJsonService } from '../index.js'
|
||||
|
||||
const schema = Joi.object({
|
||||
data: Joi.object({
|
||||
link_karma: nonNegativeInteger,
|
||||
comment_karma: nonNegativeInteger,
|
||||
link_karma: anyInteger,
|
||||
comment_karma: anyInteger,
|
||||
}).required(),
|
||||
}).required()
|
||||
|
||||
|
||||
@@ -2,16 +2,16 @@ import { isFileSize } from '../test-validators.js'
|
||||
import { createServiceTester } from '../tester.js'
|
||||
export const t = await createServiceTester()
|
||||
|
||||
t.create('EssentialsX (id 9089)')
|
||||
.get('/9089.json')
|
||||
t.create('EssentialsX (hosted resource)')
|
||||
.get('/771.json')
|
||||
.expectBadge({ label: 'size', message: isFileSize })
|
||||
|
||||
t.create('Pet Master (id 15904)').get('/15904.json').expectBadge({
|
||||
t.create('Pet Master (external resource)').get('/15904.json').expectBadge({
|
||||
lavel: 'size',
|
||||
message: 'resource hosted externally',
|
||||
})
|
||||
|
||||
t.create('Invalid Resource (id 1)').get('/1.json').expectBadge({
|
||||
t.create('Invalid Resource').get('/1.json').expectBadge({
|
||||
label: 'size',
|
||||
message: 'not found',
|
||||
})
|
||||
|
||||
@@ -75,10 +75,10 @@ async function githubLicense(githubApiProvider, user, repo) {
|
||||
|
||||
let link = `https://github.com/${repoSlug}`
|
||||
|
||||
const { buffer } = await githubApiProvider.requestAsPromise(
|
||||
const { buffer } = await githubApiProvider.requestAsPromise({
|
||||
request,
|
||||
`/repos/${repoSlug}/license`
|
||||
)
|
||||
url: `/repos/${repoSlug}/license`,
|
||||
})
|
||||
try {
|
||||
const data = JSON.parse(buffer)
|
||||
if ('html_url' in data) {
|
||||
|
||||
@@ -93,7 +93,9 @@ const isPercentage = Joi.alternatives().try(
|
||||
isDecimalPercentage
|
||||
)
|
||||
|
||||
const isFileSize = withRegex(/^[0-9]*[.]?[0-9]+\s(B|kB|MB|GB|TB|PB|EB|ZB|YB)$/)
|
||||
const isFileSize = withRegex(
|
||||
/^[0-9]*[.]?[0-9]+\s(B|kB|KB|MB|GB|TB|PB|EB|ZB|YB)$/
|
||||
)
|
||||
|
||||
const isFormattedDate = Joi.alternatives().try(
|
||||
Joi.equal('today', 'yesterday'),
|
||||
|
||||
@@ -40,6 +40,13 @@ export default class VisualStudioMarketplaceRating extends VisualStudioMarketpla
|
||||
}
|
||||
|
||||
static render({ format, averageRating, ratingCount }) {
|
||||
if (ratingCount < 1) {
|
||||
return {
|
||||
message: 'no ratings',
|
||||
color: 'inactive',
|
||||
}
|
||||
}
|
||||
|
||||
const message =
|
||||
format === 'r'
|
||||
? `${averageRating.toFixed(1)}/5 (${ratingCount})`
|
||||
|
||||
@@ -83,8 +83,8 @@ t.create('zero rating')
|
||||
)
|
||||
.expectBadge({
|
||||
label: 'rating',
|
||||
message: '0.0/5 (0)',
|
||||
color: 'red',
|
||||
message: 'no ratings',
|
||||
color: 'lightgrey',
|
||||
})
|
||||
|
||||
t.create('stars')
|
||||
|
||||
Reference in New Issue
Block a user