Compare commits

...

5 Commits

Author SHA1 Message Date
Caleb Cartwright
c6e31d7f32 load and manage persisted tokens with scope support 2021-09-19 11:41:26 -05:00
Caleb Cartwright
3aadb79325 allow github service classes to define scope requirements 2021-09-19 11:40:21 -05:00
Caleb Cartwright
b8412fd80b support scoped and unscoped tokens in API Provider 2021-09-19 11:39:48 -05:00
Caleb Cartwright
345188e34b expose token pools internal counts of held tokens 2021-09-19 11:38:53 -05:00
Caleb Cartwright
a92dc72ff5 support including scopes on oauth authorization 2021-09-19 11:37:18 -05:00
11 changed files with 521 additions and 108 deletions

View File

@@ -188,6 +188,10 @@ class TokenPool {
this.priorityQueue = new PriorityQueue(this.constructor.compareTokens)
}
count() {
return this.tokenIds.size
}
/**
* compareTokens
*

View File

@@ -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',

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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
@@ -198,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 })
}
},
})
})
}

View File

@@ -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()
},
})
})
})

View File

@@ -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
}

View File

@@ -25,17 +25,32 @@ describe('GithubAuthV3Service', function () {
}
}
it('forwards custom Accept header', async function () {
const sendAndCacheRequestWithCallbacks = sinon.stub().returns(
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 },
})
)
const githubApiProvider = new GithubApiProvider({
baseUrl: 'https://github-api.example.com',
})
const mockToken = { update: sinon.mock(), invalidate: sinon.mock() }
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({
@@ -47,7 +62,26 @@ describe('GithubAuthV3Service', function () {
headers: {
'User-Agent': 'Shields.io/2003a',
Accept: 'application/vnd.github.antiope-preview+json',
Authorization: 'token undefined',
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',

View File

@@ -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)
}

View 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)
})
})
})

View File

@@ -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) {