mirror of
https://github.com/badges/shields.git
synced 2025-04-18 19:44:04 +03:00
Improve our approach for testing auth (part 1) (#9681)
* improve stackexchange auth testing Change auth tests to include all shields of the base class. The code is formated to be used in more general cases and increases code reuseability. * Remove dummy auth test We already test all existing classes, no need for a dummy * Add getBadgeExampleCall to test-helpers Add getBadgeExampleCall to extract the first OpenAPI example then reformat it for service invoke function. * Use getBadgeExampleCall in stackexchange-base tests * Fix getBadgeExampleCall Errors * Add testAuth to test-helpers Add the testAuth function which tests auth of a service (badge) using a provided dummy response. * Refactor stackexchange-base.spec.js to use testAuth from test-helpers * Split stackexchange-base.spec into per service test file * Add all auth methods to testAuth Add all auth methods used to testAuth to be generic and used by all services. Add helper functions to make testAuth more readable * Handle non-default bearer and api headers * Add discord.spec.js as first attempt for bearer auth * Fix basic auth user * Add dynamic authorizedOrigins * Add header optional argument * Add obs as basicAuth example * Use apiHeaderKey and bearerHeaderKey function params Use apiHeaderKey & bearerHeaderKey as function params rather then extracting them with regex from function strings. Those options are now part of an options object param joined with the contentType that replaces header. header was originaly added for setting content type of the reply, so it makes more sense to directly set the content type * Remove old comment * Allow any pass & user key for QueryStringAuth Before this commit the QueryStringAuth would only work for the key of stackexchange. This commit makes the testAuth function generic and allows passing user and pass keys. * Add auth test for PepyDownloads * Fix wrong header for jwt login Might set wrong header for jwt login request. This commit fixes that. * Support multiple authOrigins in testAuth Some services might have more then one authOrigin. This commit makes sure we test for redundent authOrigins as well as support requests to them if needed. * Add docker-automated auth test * Fix JwtAuth testing by introducing mandatory jwtLoginEndpoint Prior to this change, JwtAuth testing would lead to erros due to the absence of a specified login endpoint, Nock would be dumplicated for both login and non login hosts and indicate a missing request. This commit enforces the requirement for a new jwtLoginEndpoint argument when testing JwtAuth. The argument seperates the endpoint nock scope from the behavior of the request nock. * Fix type test in generateFakeConfig
This commit is contained in:
parent
503764e6b9
commit
8ab9dfa9a1
@ -1,38 +1,15 @@
|
||||
import { expect } from 'chai'
|
||||
import nock from 'nock'
|
||||
import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js'
|
||||
import { testAuth } from '../test-helpers.js'
|
||||
import Discord from './discord.service.js'
|
||||
|
||||
describe('Discord', function () {
|
||||
cleanUpNockAfterEach()
|
||||
|
||||
it('sends the auth information as configured', async function () {
|
||||
const pass = 'password'
|
||||
const config = {
|
||||
private: {
|
||||
discord_bot_token: pass,
|
||||
},
|
||||
}
|
||||
|
||||
const scope = nock('https://discord.com', {
|
||||
// This ensures that the expected credential is actually being sent with the HTTP request.
|
||||
// Without this the request wouldn't match and the test would fail.
|
||||
reqheaders: { Authorization: 'Bot password' },
|
||||
describe('auth', function () {
|
||||
it('sends the auth information as configured', async function () {
|
||||
return testAuth(
|
||||
Discord,
|
||||
'BearerAuthHeader',
|
||||
{ presence_count: 125 },
|
||||
{ bearerHeaderKey: 'Bot' },
|
||||
)
|
||||
})
|
||||
.get('/api/v6/guilds/12345/widget.json')
|
||||
.reply(200, {
|
||||
presence_count: 125,
|
||||
})
|
||||
|
||||
expect(
|
||||
await Discord.invoke(defaultContext, config, {
|
||||
serverId: '12345',
|
||||
}),
|
||||
).to.deep.equal({
|
||||
message: '125 online',
|
||||
color: 'brightgreen',
|
||||
})
|
||||
|
||||
scope.done()
|
||||
})
|
||||
})
|
||||
|
15
services/docker/docker-automated.spec.js
Normal file
15
services/docker/docker-automated.spec.js
Normal file
@ -0,0 +1,15 @@
|
||||
import { testAuth } from '../test-helpers.js'
|
||||
import DockerAutomatedBuild from './docker-automated.service.js'
|
||||
|
||||
describe('DockerAutomatedBuild', function () {
|
||||
describe('auth', function () {
|
||||
it('sends the auth information as configured', async function () {
|
||||
return testAuth(
|
||||
DockerAutomatedBuild,
|
||||
'JwtAuth',
|
||||
{ is_automated: true },
|
||||
{ jwtLoginEndpoint: 'https://hub.docker.com/v2/users/login/' },
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
16
services/obs/obs.spec.js
Normal file
16
services/obs/obs.spec.js
Normal file
@ -0,0 +1,16 @@
|
||||
import { testAuth } from '../test-helpers.js'
|
||||
import ObsService from './obs.service.js'
|
||||
|
||||
describe('ObsService', function () {
|
||||
describe('auth', function () {
|
||||
it('sends the auth information as configured', async function () {
|
||||
return testAuth(
|
||||
ObsService,
|
||||
'BasicAuth',
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<status package="example" code="passed"></status>`,
|
||||
{ contentType: 'application/xml' },
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
10
services/pepy/pepy-downloads.spec.js
Normal file
10
services/pepy/pepy-downloads.spec.js
Normal file
@ -0,0 +1,10 @@
|
||||
import { testAuth } from '../test-helpers.js'
|
||||
import PepyDownloads from './pepy-downloads.service.js'
|
||||
|
||||
describe('PepyDownloads', function () {
|
||||
describe('auth', function () {
|
||||
it('sends the auth information as configured', async function () {
|
||||
return testAuth(PepyDownloads, 'ApiKeyHeader', { total_downloads: 42 })
|
||||
})
|
||||
})
|
||||
})
|
@ -1,38 +0,0 @@
|
||||
import Joi from 'joi'
|
||||
import { expect } from 'chai'
|
||||
import nock from 'nock'
|
||||
import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js'
|
||||
import { StackExchangeBase } from './stackexchange-base.js'
|
||||
|
||||
class DummyStackExchangeService extends StackExchangeBase {
|
||||
static route = { base: 'fake-base' }
|
||||
|
||||
async handle() {
|
||||
const data = await this.fetch({
|
||||
schema: Joi.any(),
|
||||
url: 'https://api.stackexchange.com/2.2/tags/python/info',
|
||||
})
|
||||
return { message: data.message }
|
||||
}
|
||||
}
|
||||
|
||||
describe('StackExchangeBase', function () {
|
||||
describe('auth', function () {
|
||||
cleanUpNockAfterEach()
|
||||
|
||||
const config = { private: { stackapps_api_key: 'fake-key' } }
|
||||
|
||||
it('sends the auth information as configured', async function () {
|
||||
const scope = nock('https://api.stackexchange.com')
|
||||
.get('/2.2/tags/python/info')
|
||||
.query({ key: 'fake-key' })
|
||||
.reply(200, { message: 'fake message' })
|
||||
|
||||
expect(
|
||||
await DummyStackExchangeService.invoke(defaultContext, config, {}),
|
||||
).to.deep.equal({ message: 'fake message' })
|
||||
|
||||
scope.done()
|
||||
})
|
||||
})
|
||||
})
|
@ -0,0 +1,17 @@
|
||||
import { testAuth } from '../test-helpers.js'
|
||||
import StackExchangeMonthlyQuestions from './stackexchange-monthlyquestions.service.js'
|
||||
|
||||
describe('StackExchangeMonthlyQuestions', function () {
|
||||
describe('auth', function () {
|
||||
it('sends the auth information as configured', async function () {
|
||||
return testAuth(
|
||||
StackExchangeMonthlyQuestions,
|
||||
'QueryStringAuth',
|
||||
{
|
||||
total: 8,
|
||||
},
|
||||
{ queryPassKey: 'key' },
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
17
services/stackexchange/stackexchange-reputation.spec.js
Normal file
17
services/stackexchange/stackexchange-reputation.spec.js
Normal file
@ -0,0 +1,17 @@
|
||||
import { testAuth } from '../test-helpers.js'
|
||||
import StackExchangeReputation from './stackexchange-reputation.service.js'
|
||||
|
||||
describe('StackExchangeReputation', function () {
|
||||
describe('auth', function () {
|
||||
it('sends the auth information as configured', async function () {
|
||||
return testAuth(
|
||||
StackExchangeReputation,
|
||||
'QueryStringAuth',
|
||||
{
|
||||
items: [{ reputation: 8 }],
|
||||
},
|
||||
{ queryPassKey: 'key' },
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
17
services/stackexchange/stackexchange-taginfo.spec.js
Normal file
17
services/stackexchange/stackexchange-taginfo.spec.js
Normal file
@ -0,0 +1,17 @@
|
||||
import { testAuth } from '../test-helpers.js'
|
||||
import StackExchangeQuestions from './stackexchange-taginfo.service.js'
|
||||
|
||||
describe('StackExchangeQuestions', function () {
|
||||
describe('auth', function () {
|
||||
it('sends the auth information as configured', async function () {
|
||||
return testAuth(
|
||||
StackExchangeQuestions,
|
||||
'QueryStringAuth',
|
||||
{
|
||||
items: [{ count: 8 }],
|
||||
},
|
||||
{ queryPassKey: 'key' },
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
@ -1,6 +1,9 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { expect } from 'chai'
|
||||
import nock from 'nock'
|
||||
import config from 'config'
|
||||
import { fetch } from '../core/base-service/got.js'
|
||||
import BaseService from '../core/base-service/base.js'
|
||||
const runnerConfig = config.util.toObject()
|
||||
|
||||
function cleanUpNockAfterEach() {
|
||||
@ -30,6 +33,314 @@ function noToken(serviceClass) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an example set of parameters for invoking a service class using OpenAPI example of that class.
|
||||
*
|
||||
* @param {BaseService} serviceClass The service class containing OpenAPI specifications.
|
||||
* @returns {object} An object with call params to use with a service invoke of the first OpenAPI example.
|
||||
* @throws {TypeError} - Throws a TypeError if the input `serviceClass` is not an instance of BaseService,
|
||||
* or if it lacks the expected structure.
|
||||
*
|
||||
* @example
|
||||
* // Example usage:
|
||||
* const example = getBadgeExampleCall(StackExchangeReputation)
|
||||
* console.log(example)
|
||||
* // Output: { stackexchangesite: 'stackoverflow', query: '123' }
|
||||
* StackExchangeReputation.invoke(defaultContext, config, example)
|
||||
*/
|
||||
function getBadgeExampleCall(serviceClass) {
|
||||
if (!(serviceClass.prototype instanceof BaseService)) {
|
||||
throw new TypeError(
|
||||
'Invalid serviceClass: Must be an instance of BaseService.',
|
||||
)
|
||||
}
|
||||
|
||||
if (!serviceClass.openApi) {
|
||||
throw new TypeError(
|
||||
`Missing OpenAPI in service class ${serviceClass.name}.`,
|
||||
)
|
||||
}
|
||||
|
||||
const firstOpenapiPath = Object.keys(serviceClass.openApi)[0]
|
||||
|
||||
const firstOpenapiExampleParams =
|
||||
serviceClass.openApi[firstOpenapiPath].get.parameters
|
||||
if (!Array.isArray(firstOpenapiExampleParams)) {
|
||||
throw new TypeError(
|
||||
`Missing or invalid OpenAPI examples in ${serviceClass.name}.`,
|
||||
)
|
||||
}
|
||||
|
||||
// reformat structure for serviceClass.invoke
|
||||
const exampleInvokeParams = firstOpenapiExampleParams.reduce((acc, obj) => {
|
||||
acc[obj.name] = obj.example
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return exampleInvokeParams
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a configuration object with a fake key based on the provided class.
|
||||
* For use in auth tests where a config with a test key is required.
|
||||
*
|
||||
* @param {BaseService} serviceClass - The class to generate configuration for.
|
||||
* @param {string} fakeKey - The fake key to be used in the configuration.
|
||||
* @param {string} fakeUser - Optional, The fake user to be used in the configuration.
|
||||
* @param {string} fakeauthorizedOrigins - authorizedOrigins to add to config.
|
||||
* @returns {object} - The configuration object.
|
||||
* @throws {TypeError} - Throws an error if the input is not a class.
|
||||
*/
|
||||
function generateFakeConfig(
|
||||
serviceClass,
|
||||
fakeKey,
|
||||
fakeUser,
|
||||
fakeauthorizedOrigins,
|
||||
) {
|
||||
if (
|
||||
!serviceClass ||
|
||||
!serviceClass.prototype ||
|
||||
!(serviceClass.prototype instanceof BaseService)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Invalid serviceClass: Must be an instance of BaseService.',
|
||||
)
|
||||
}
|
||||
if (!fakeKey || typeof fakeKey !== 'string') {
|
||||
throw new TypeError('Invalid fakeKey: Must be a String.')
|
||||
}
|
||||
if (!fakeauthorizedOrigins || !Array.isArray(fakeauthorizedOrigins)) {
|
||||
throw new TypeError('Invalid fakeauthorizedOrigins: Must be an array.')
|
||||
}
|
||||
|
||||
if (!serviceClass.auth) {
|
||||
throw new Error(`Missing auth for ${serviceClass.name}.`)
|
||||
}
|
||||
if (!serviceClass.auth.passKey) {
|
||||
throw new Error(`Missing auth.passKey for ${serviceClass.name}.`)
|
||||
}
|
||||
// Extract the passKey property from auth, or use a default if not present
|
||||
const passKeyProperty = serviceClass.auth.passKey
|
||||
let passUserProperty = 'placeholder'
|
||||
if (fakeUser) {
|
||||
if (typeof fakeKey !== 'string') {
|
||||
throw new TypeError('Invalid fakeUser: Must be a String.')
|
||||
}
|
||||
if (!serviceClass.auth.userKey) {
|
||||
throw new Error(`Missing auth.userKey for ${serviceClass.name}.`)
|
||||
}
|
||||
passUserProperty = serviceClass.auth.userKey
|
||||
}
|
||||
|
||||
// Build and return the configuration object with the fake key
|
||||
return {
|
||||
public: {
|
||||
services: {
|
||||
[serviceClass.auth.serviceKey]: {
|
||||
authorizedOrigins: fakeauthorizedOrigins,
|
||||
},
|
||||
},
|
||||
},
|
||||
private: {
|
||||
[passKeyProperty]: fakeKey,
|
||||
[passUserProperty]: fakeUser,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first auth origin found for a provided service class.
|
||||
*
|
||||
* @param {BaseService} serviceClass The service class to find the authorized origins.
|
||||
* @throws {TypeError} - Throws a TypeError if the input `serviceClass` is not an instance of BaseService.
|
||||
* @returns {string} First auth origin found.
|
||||
*
|
||||
* @example
|
||||
* // Example usage:
|
||||
* getServiceClassAuthOrigin(Obs)
|
||||
* // outputs 'https://api.opensuse.org'
|
||||
*/
|
||||
function getServiceClassAuthOrigin(serviceClass) {
|
||||
if (
|
||||
!serviceClass ||
|
||||
!serviceClass.prototype ||
|
||||
!(serviceClass.prototype instanceof BaseService)
|
||||
) {
|
||||
throw new TypeError(
|
||||
`Invalid serviceClass ${serviceClass}: Must be an instance of BaseService.`,
|
||||
)
|
||||
}
|
||||
if (serviceClass.auth.authorizedOrigins) {
|
||||
return serviceClass.auth.authorizedOrigins
|
||||
} else {
|
||||
return [
|
||||
config.public.services[serviceClass.auth.serviceKey].authorizedOrigins,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fake JWT Token valid for 1 hour for use in testing.
|
||||
*
|
||||
* @returns {string} Fake JWT Token valid for 1 hour.
|
||||
*/
|
||||
function fakeJwtToken() {
|
||||
const fakeJwtPayload = { exp: dayjs().add(1, 'hours').unix() }
|
||||
const fakeJwtPayloadJsonString = JSON.stringify(fakeJwtPayload)
|
||||
const fakeJwtPayloadBase64 = Buffer.from(fakeJwtPayloadJsonString).toString(
|
||||
'base64',
|
||||
)
|
||||
const jwtToken = `FakeHeader.${fakeJwtPayloadBase64}.fakeSignature`
|
||||
return jwtToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Test authentication of a badge for it's first OpenAPI example using a provided dummyResponse and authentication method.
|
||||
*
|
||||
* @param {BaseService} serviceClass The service class tested.
|
||||
* @param {'BasicAuth'|'ApiKeyHeader'|'BearerAuthHeader'|'QueryStringAuth'|'JwtAuth'} authMethod The auth method of the tested service class.
|
||||
* @param {object} dummyResponse An object containing the dummy response by the server.
|
||||
* @param {object} options - Additional options for non default keys and content-type of the dummy response.
|
||||
* @param {'application/xml'|'application/json'} options.contentType - Header for the response, may contain any string.
|
||||
* @param {string} options.apiHeaderKey - Non default header for ApiKeyHeader auth.
|
||||
* @param {string} options.bearerHeaderKey - Non default bearer header prefix for BearerAuthHeader.
|
||||
* @param {string} options.queryUserKey - QueryStringAuth user key.
|
||||
* @param {string} options.queryPassKey - QueryStringAuth pass key.
|
||||
* @param {string} options.jwtLoginEndpoint - jwtAuth Login endpoint.
|
||||
* @throws {TypeError} - Throws a TypeError if the input `serviceClass` is not an instance of BaseService,
|
||||
* or if `serviceClass` is missing authorizedOrigins.
|
||||
*
|
||||
* @example
|
||||
* // Example usage:
|
||||
* testAuth(StackExchangeReputation, QueryStringAuth, { items: [{ reputation: 8 }] })
|
||||
*/
|
||||
async function testAuth(serviceClass, authMethod, dummyResponse, options = {}) {
|
||||
if (!(serviceClass.prototype instanceof BaseService)) {
|
||||
throw new TypeError(
|
||||
'Invalid serviceClass: Must be an instance of BaseService.',
|
||||
)
|
||||
}
|
||||
|
||||
cleanUpNockAfterEach()
|
||||
|
||||
const fakeUser = serviceClass.auth.userKey ? 'fake-user' : undefined
|
||||
const fakeSecret = 'fake-secret'
|
||||
const authOrigins = getServiceClassAuthOrigin(serviceClass)
|
||||
const config = generateFakeConfig(
|
||||
serviceClass,
|
||||
fakeSecret,
|
||||
fakeUser,
|
||||
authOrigins,
|
||||
)
|
||||
const exampleInvokeParams = getBadgeExampleCall(serviceClass)
|
||||
if (options && typeof options !== 'object') {
|
||||
throw new TypeError('Invalid options: Must be an object.')
|
||||
}
|
||||
const {
|
||||
contentType,
|
||||
apiHeaderKey = 'x-api-key',
|
||||
bearerHeaderKey = 'Bearer',
|
||||
queryUserKey,
|
||||
queryPassKey,
|
||||
jwtLoginEndpoint,
|
||||
} = options
|
||||
if (contentType && typeof contentType !== 'string') {
|
||||
throw new TypeError('Invalid contentType: Must be a String.')
|
||||
}
|
||||
const header = contentType ? { 'Content-Type': contentType } : undefined
|
||||
if (!apiHeaderKey || typeof apiHeaderKey !== 'string') {
|
||||
throw new TypeError('Invalid apiHeaderKey: Must be a String.')
|
||||
}
|
||||
if (!bearerHeaderKey || typeof bearerHeaderKey !== 'string') {
|
||||
throw new TypeError('Invalid bearerHeaderKey: Must be a String.')
|
||||
}
|
||||
|
||||
if (!authOrigins) {
|
||||
throw new TypeError(`Missing authorizedOrigins for ${serviceClass.name}.`)
|
||||
}
|
||||
const jwtToken = authMethod === 'JwtAuth' ? fakeJwtToken() : undefined
|
||||
|
||||
const scopeArr = []
|
||||
authOrigins.forEach(authOrigin => {
|
||||
const scope = nock(authOrigin)
|
||||
scopeArr.push(scope)
|
||||
switch (authMethod) {
|
||||
case 'BasicAuth':
|
||||
scope
|
||||
.get(/.*/)
|
||||
.basicAuth({ user: fakeUser, pass: fakeSecret })
|
||||
.reply(200, dummyResponse, header)
|
||||
break
|
||||
case 'ApiKeyHeader':
|
||||
scope
|
||||
.get(/.*/)
|
||||
.matchHeader(apiHeaderKey, fakeSecret)
|
||||
.reply(200, dummyResponse, header)
|
||||
break
|
||||
case 'BearerAuthHeader':
|
||||
scope
|
||||
.get(/.*/)
|
||||
.matchHeader('Authorization', `${bearerHeaderKey} ${fakeSecret}`)
|
||||
.reply(200, dummyResponse, header)
|
||||
break
|
||||
case 'QueryStringAuth':
|
||||
if (!queryPassKey || typeof queryPassKey !== 'string') {
|
||||
throw new TypeError('Invalid queryPassKey: Must be a String.')
|
||||
}
|
||||
scope
|
||||
.get(/.*/)
|
||||
.query(queryObject => {
|
||||
if (queryObject[queryPassKey] !== fakeSecret) {
|
||||
return false
|
||||
}
|
||||
if (queryUserKey) {
|
||||
if (typeof queryUserKey !== 'string') {
|
||||
throw new TypeError('Invalid queryUserKey: Must be a String.')
|
||||
}
|
||||
if (queryObject[queryUserKey] !== fakeUser) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
.reply(200, dummyResponse, header)
|
||||
break
|
||||
case 'JwtAuth': {
|
||||
if (!jwtLoginEndpoint || typeof jwtLoginEndpoint !== 'string') {
|
||||
throw new TypeError('Invalid jwtLoginEndpoint: Must be a String.')
|
||||
}
|
||||
if (jwtLoginEndpoint.startsWith(authOrigin)) {
|
||||
scope
|
||||
.post(/.*/, { username: fakeUser, password: fakeSecret })
|
||||
.reply(200, { token: jwtToken })
|
||||
} else {
|
||||
scope
|
||||
.get(/.*/)
|
||||
.matchHeader('Authorization', `Bearer ${jwtToken}`)
|
||||
.reply(200, dummyResponse, header)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
throw new TypeError(`Unkown auth method for ${serviceClass.name}.`)
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
await serviceClass.invoke(defaultContext, config, exampleInvokeParams),
|
||||
).to.not.have.property('isError')
|
||||
|
||||
// if we get 'Mocks not yet satisfied' we have redundent authOrigins or we are missing a critical request
|
||||
scopeArr.forEach(scope => scope.done())
|
||||
}
|
||||
|
||||
const defaultContext = { requestFetcher: fetch }
|
||||
|
||||
export { cleanUpNockAfterEach, noToken, defaultContext }
|
||||
export {
|
||||
cleanUpNockAfterEach,
|
||||
noToken,
|
||||
getBadgeExampleCall,
|
||||
testAuth,
|
||||
defaultContext,
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user