import _ from 'lodash'
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() {
afterEach(function () {
nock.restore()
nock.cleanAll()
nock.enableNetConnect()
nock.activate()
})
}
function noToken(serviceClass) {
let hasLogged = false
return () => {
const userKey = serviceClass.auth.userKey
const passKey = serviceClass.auth.passKey
const noToken =
(userKey && !runnerConfig.private[userKey]) ||
(passKey && !runnerConfig.private[passKey])
if (noToken && !hasLogged) {
console.warn(
`${serviceClass.name}: no credentials configured, tests for this service will be skipped. Add credentials in local.yml to run them.`,
)
hasLogged = true
}
return noToken
}
}
/**
* 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.
* @param {'path'|'query'} paramType The type of params to extract, may be path params or query params.
* @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, paramType) {
if (!(serviceClass.prototype instanceof BaseService)) {
throw new TypeError(
'Invalid serviceClass: Must be an instance of BaseService.',
)
}
if (Object.keys(serviceClass.openApi).length === 0) {
console.warn(
`Missing OpenAPI in service class ${serviceClass.name}. Make sure to use exampleOverride in testAuth.`,
)
return {}
}
if (!['path', 'query'].includes(paramType)) {
throw new TypeError('Invalid paramType: Must be path or query.')
}
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) => {
if (obj.in === paramType) {
let example = obj.example
if (obj?.schema?.type === 'boolean') {
example = example || ''
}
acc[obj.name] = 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.
* @param {object} authOverride Return result with overrid params.
* @returns {object} - The configuration object.
* @throws {TypeError} - Throws an error if the input is not a class.
*/
function generateFakeConfig(
serviceClass,
fakeKey,
fakeUser,
fakeauthorizedOrigins,
authOverride,
) {
if (
!serviceClass ||
!serviceClass.prototype ||
!(serviceClass.prototype instanceof BaseService)
) {
throw new TypeError(
'Invalid serviceClass: Must be an instance of BaseService.',
)
}
if (!fakeKey && !fakeUser) {
throw new TypeError('Must provide at least one: fakeKey or fakeUser.')
}
if (!fakeauthorizedOrigins || !Array.isArray(fakeauthorizedOrigins)) {
throw new TypeError('Invalid fakeauthorizedOrigins: Must be an array.')
}
const auth = { ...serviceClass.auth, ...authOverride }
if (Object.keys(auth).length === 0) {
throw new Error(`Auth empty for ${serviceClass.name}.`)
}
if (fakeKey && typeof fakeKey !== 'string') {
throw new Error('Invalid fakeKey: Must be a String.')
}
if (fakeKey && !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 = auth.passKey ? auth.passKey : undefined
if (fakeUser && typeof fakeUser !== 'string') {
throw new TypeError('Invalid fakeUser: Must be a String.')
}
if (fakeUser && !auth.userKey) {
throw new Error(`Missing auth.userKey for ${serviceClass.name}.`)
}
const passUserProperty = auth.userKey ? auth.userKey : undefined
// Build and return the configuration object with the fake key
return {
public: {
services: {
[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.
* @param {object} authOverride Return result with overridden params.
* @param {object} configOverride - Override the config.
* @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, authOverride, configOverride) {
if (
!serviceClass ||
!serviceClass.prototype ||
!(serviceClass.prototype instanceof BaseService)
) {
throw new TypeError(
`Invalid serviceClass ${serviceClass}: Must be an instance of BaseService.`,
)
}
const auth = { ...serviceClass.auth, ...authOverride }
if (auth.authorizedOrigins) {
return auth.authorizedOrigins
} else {
const mergedConfig = _.merge(runnerConfig, configOverride)
if (!mergedConfig.public.services[auth.serviceKey]) {
throw new TypeError(
`Missing service key definition for ${auth.serviceKey}: Use an override if applicable.`,
)
}
return [mergedConfig.public.services[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 {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.
* @param {object} options.exampleOverride - Override example params in test.
* @param {object} options.authOverride - Override class auth params.
* @param {object} options.configOverride - Override the config for this test.
* @param {boolean} options.multipleRequests - For classes that require multiple requests to complete the test.
* @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.',
)
}
const {
apiHeaderKey = 'x-api-key',
bearerHeaderKey = 'Bearer',
queryUserKey,
queryPassKey,
jwtLoginEndpoint,
exampleOverride = {},
authOverride,
configOverride,
multipleRequests = false,
} = options
const header = serviceClass.headers
? { 'Content-Type': serviceClass.headers.Accept.split(', ')[0] }
: 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 (typeof exampleOverride !== 'object') {
throw new TypeError('Invalid exampleOverride: Must be an Object.')
}
if (authOverride && typeof authOverride !== 'object') {
throw new TypeError('Invalid authOverride: Must be an Object.')
}
if (configOverride && typeof configOverride !== 'object') {
throw new TypeError('Invalid configOverride: Must be an Object.')
}
if (multipleRequests && typeof multipleRequests !== 'boolean') {
throw new TypeError('Invalid multipleRequests: Must be a boolean.')
}
if (!multipleRequests) {
cleanUpNockAfterEach()
}
const auth = { ...serviceClass.auth, ...authOverride }
const fakeUser = auth.userKey
? 'fake-user'
: auth.defaultToEmptyStringForUser
? ''
: undefined
const fakeSecret = auth.passKey ? 'fake-secret' : undefined
if (!fakeUser && !fakeSecret) {
throw new TypeError(
`Missing auth pass/user for ${serviceClass.name}. At least one is required.`,
)
}
const authOrigins = getServiceClassAuthOrigin(
serviceClass,
authOverride,
configOverride,
)
const config = generateFakeConfig(
serviceClass,
fakeSecret,
fakeUser,
authOrigins,
authOverride,
)
const exampleInvokePathParams = getBadgeExampleCall(serviceClass, 'path')
const exampleInvokeQueryParams = getBadgeExampleCall(serviceClass, 'query')
if (options && typeof options !== 'object') {
throw new TypeError('Invalid options: Must be an object.')
}
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)
if (multipleRequests) {
scope.persist()
}
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(/.*/, body => {
if (typeof body === 'object') {
return (
body.username === fakeUser && body.password === fakeSecret
)
}
if (typeof body === 'string') {
return (
body.includes(`username=${encodeURIComponent(fakeUser)}`) &&
body.includes(`password=${encodeURIComponent(fakeSecret)}`)
)
}
return false
})
.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,
_.merge(config, configOverride),
{
...exampleInvokePathParams,
...exampleOverride,
},
{
...exampleInvokeQueryParams,
...exampleOverride,
},
),
).to.not.have.property('isError')
// clean up persistance if we have multiple requests
if (multipleRequests) {
scopeArr.forEach(scope => scope.persist(false))
nock.restore()
nock.cleanAll()
nock.enableNetConnect()
nock.activate()
}
// 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,
getBadgeExampleCall,
testAuth,
defaultContext,
}