diff --git a/services/discord/discord.spec.js b/services/discord/discord.spec.js index c563ea826d423..4da1d14de5720 100644 --- a/services/discord/discord.spec.js +++ b/services/discord/discord.spec.js @@ -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() }) }) diff --git a/services/docker/docker-automated.spec.js b/services/docker/docker-automated.spec.js new file mode 100644 index 0000000000000..5d9ce4801b8ac --- /dev/null +++ b/services/docker/docker-automated.spec.js @@ -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/' }, + ) + }) + }) +}) diff --git a/services/obs/obs.spec.js b/services/obs/obs.spec.js new file mode 100644 index 0000000000000..fa19d9868b2f9 --- /dev/null +++ b/services/obs/obs.spec.js @@ -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', + ` + `, + { contentType: 'application/xml' }, + ) + }) + }) +}) diff --git a/services/pepy/pepy-downloads.spec.js b/services/pepy/pepy-downloads.spec.js new file mode 100644 index 0000000000000..9a529af63cf1f --- /dev/null +++ b/services/pepy/pepy-downloads.spec.js @@ -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 }) + }) + }) +}) diff --git a/services/stackexchange/stackexchange-base.spec.js b/services/stackexchange/stackexchange-base.spec.js deleted file mode 100644 index 771cf7be32470..0000000000000 --- a/services/stackexchange/stackexchange-base.spec.js +++ /dev/null @@ -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() - }) - }) -}) diff --git a/services/stackexchange/stackexchange-monthlyquestions.spec.js b/services/stackexchange/stackexchange-monthlyquestions.spec.js new file mode 100644 index 0000000000000..b014718eaefc0 --- /dev/null +++ b/services/stackexchange/stackexchange-monthlyquestions.spec.js @@ -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' }, + ) + }) + }) +}) diff --git a/services/stackexchange/stackexchange-reputation.spec.js b/services/stackexchange/stackexchange-reputation.spec.js new file mode 100644 index 0000000000000..b0bcd9be17732 --- /dev/null +++ b/services/stackexchange/stackexchange-reputation.spec.js @@ -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' }, + ) + }) + }) +}) diff --git a/services/stackexchange/stackexchange-taginfo.spec.js b/services/stackexchange/stackexchange-taginfo.spec.js new file mode 100644 index 0000000000000..46977c1ced29b --- /dev/null +++ b/services/stackexchange/stackexchange-taginfo.spec.js @@ -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' }, + ) + }) + }) +}) diff --git a/services/test-helpers.js b/services/test-helpers.js index 8fb099ba05b3a..b1368389c33c5 100644 --- a/services/test-helpers.js +++ b/services/test-helpers.js @@ -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, +}