Source: services/test-helpers.js

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.
 * @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,
  getBadgeExampleCall,
  testAuth,
  defaultContext,
}