diff --git a/Control/index.js b/Control/index.js index 05c0d798e..baba603db 100644 --- a/Control/index.js +++ b/Control/index.js @@ -19,6 +19,13 @@ const config = require('./lib/config/configProvider.js'); const {buildPublicConfig} = require('./lib/config/publicConfigProvider.js'); const api = require('./lib/api.js'); + +// Initialize nock for Consul only if we are in test environment +if (process.env.NODE_ENV === "test") { + const { initializeNockForConsul } = require("./test/config/testConfigForConsul.js"); + initializeNockForConsul(); +} + // ------------------------------------------------------- buildPublicConfig(config); diff --git a/Control/lib/api.js b/Control/lib/api.js index c02ab4d02..93a37113c 100644 --- a/Control/lib/api.js +++ b/Control/lib/api.js @@ -25,6 +25,8 @@ const {addDetectorIdMiddleware} = require('./middleware/addDetectorId.middleware const {logDeploymentRequestMiddleware} = require('./middleware/logDeploymentRequest.middleware.js'); const {minimumRoleMiddleware} = require('./middleware/minimumRole.middleware.js'); const {requireDetectorOrGlobalRoleMiddleware} = require('./middleware/requireDetectorOrGlobalRole.middleware.js'); +const {validateConsulServiceMiddlewareFactory} = require('./middleware/validateConsulServiceMiddlewareFactory.js'); + const { setDetectorsFromEnvironmentMiddlewareFactory } = require('./middleware/setDetectorsFromEnvironmentMiddlewareFactory.js'); @@ -33,6 +35,7 @@ const { } = require('./middleware/getDetectorsLockOwnershipMiddlewareFactory.js'); // controllers +const {QCConfigurationController} = require('./controllers/QCConfiguration.controller.js'); const {ConsulController} = require('./controllers/Consul.controller.js'); const {DeploymentController} = require('./controllers/Deployment.controller.js'); const {EnvironmentController} = require('./controllers/Environment.controller.js'); @@ -57,6 +60,7 @@ const {RunService} = require('./services/Run.service.js'); const {StatusService} = require('./services/Status.service.js'); const {TaskService} = require('./services/Task.service.js'); const {WorkflowTemplateService} = require('./services/WorkflowTemplate.service.js'); +const {QCConfigurationService} = require('./services/QCConfiguration.service.js'); // web-ui services const {NotificationService, ConsulService} = require('@aliceo2/web-ui'); @@ -99,8 +103,10 @@ module.exports.setup = (http, ws) => { const cacheService = new CacheService(broadcastService); const environmentCacheService = new EnvironmentCacheService(broadcastService, eventEmitter); + const qcConfigurationService = new QCConfigurationService(consulService); + const qcConfigurationController = new QCConfigurationController(qcConfigurationService, config.consul); + const consulController = new ConsulController(consulService, config.consul); - consulController.testConsulStatus(); const ctrlProxy = new GrpcServiceClient(config.grpc, O2_CONTROL_PROTO_PATH); const ctrlService = new ControlService(ctrlProxy, consulController, config.grpc, O2_CONTROL_PROTO_PATH); @@ -161,7 +167,7 @@ module.exports.setup = (http, ws) => { const intervals = new Intervals(); - initializeData(apricotService, lockService); + initializeData(apricotService, lockService, consulService); initializeIntervals(intervals, statusService, runService, bkpService, environmentService); const coreMiddleware = [ @@ -169,6 +175,7 @@ module.exports.setup = (http, ws) => { ]; const setDetectorsFromEnvironmentMiddleware = setDetectorsFromEnvironmentMiddlewareFactory(environmentService); const verifyLockOwnershipMiddleware = getDetectorsLockOwnershipMiddlewareFactory(lockService); + const validateConsulServiceMiddleware = validateConsulServiceMiddlewareFactory(consulService); ctrlProxy.methods.forEach( (method) => http.post(`/${method}`, coreMiddleware, (req, res) => ctrlService.executeCommand(req, res)), @@ -270,13 +277,31 @@ module.exports.setup = (http, ws) => { statusController.getAliECSIntegratedServicesStatus.bind(statusController), ); + // Configuration + http.get( + '/configurations', validateConsulServiceMiddleware, + qcConfigurationController.getConfigurationsKeysHandler.bind(qcConfigurationController) + ); + http.get( + '/configurations/:key(*)', validateConsulServiceMiddleware, + qcConfigurationController.getConfigurationByKeyHandler.bind(qcConfigurationController) + ); + // Consul - const validateService = consulController.validateService.bind(consulController); - http.get('/consul/flps', validateService, consulController.getFLPs.bind(consulController)); - http.get('/consul/crus', validateService, consulController.getCRUs.bind(consulController)); - http.get('/consul/crus/config', validateService, consulController.getCRUsWithConfiguration.bind(consulController)); - http.get('/consul/crus/aliases', validateService, consulController.getCRUsAlias.bind(consulController)); - http.post('/consul/crus/config/save', validateService, consulController.saveCRUsConfiguration.bind(consulController)); + http.get('/consul/flps', validateConsulServiceMiddleware, consulController.getFLPs.bind(consulController)); + http.get('/consul/crus', validateConsulServiceMiddleware, consulController.getCRUs.bind(consulController)); + http.get( + '/consul/crus/config', validateConsulServiceMiddleware, + consulController.getCRUsWithConfiguration.bind(consulController) + ); + http.get( + '/consul/crus/aliases', validateConsulServiceMiddleware, + consulController.getCRUsAlias.bind(consulController) + ); + http.post( + '/consul/crus/config/save', validateConsulServiceMiddleware, + consulController.saveCRUsConfiguration.bind(consulController) + ); }; /** @@ -320,8 +345,21 @@ function initializeIntervals(intervalsService, statusService, runService, bkpSer * Function to initialize in order dependent services * @param {ApricotService} apricotService - request initial set of data from AliECS/Apricot * @param {LockService} lockService - initialize service with data from Apricot + * @param {ConsulService} consulService - service for communicating with Consul */ -async function initializeData(apricotService, lockService) { +async function initializeData(apricotService, lockService, consulService) { + testConsulStatus(consulService); await apricotService.init(); lockService.setLockStatesForDetectors(apricotService.detectors); } + +/** + * Method to check if consul service can be used + * @param {ConsulService} consulService + */ +function testConsulStatus(consulService) { + consulService + .getConsulLeaderStatus() + .then((data) => logger.info(`Service is up and running on: ${data}`)) + .catch((error) => logger.error(`Connection failed due to ${error}`)); +} diff --git a/Control/lib/controllers/Consul.controller.js b/Control/lib/controllers/Consul.controller.js index 70cc80ade..285f5e7ae 100644 --- a/Control/lib/controllers/Consul.controller.js +++ b/Control/lib/controllers/Consul.controller.js @@ -39,31 +39,6 @@ class ConsulController { this._logger = LogManager.getLogger(`${process.env.npm_config_log_label ?? 'cog'}/consul`); } - /** - * Check if consulService is present: - * * If yes, allow request to continue - * * If not, send response accordingly - * @param {Request} req - * @param {Response} res - * @param {Next} next - */ - validateService(req, res, next) { - if (this.consulService) { - next(); - } else { - errorHandler('Unable to retrieve configuration of consul service', res, 502); - } - } - - /** - * Method to check if consul service can be used - */ - async testConsulStatus() { - this.consulService.getConsulLeaderStatus() - .then((data) => this._logger.info(`Service is up and running on: ${data}`)) - .catch((error) => this._logger.error(`Connection failed due to ${error}`)); - } - /** * Method to request all CRUs available in consul KV store under the * hardware key diff --git a/Control/lib/controllers/QCConfiguration.controller.js b/Control/lib/controllers/QCConfiguration.controller.js new file mode 100644 index 000000000..8e359ed51 --- /dev/null +++ b/Control/lib/controllers/QCConfiguration.controller.js @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const { + LogManager, + updateAndSendExpressResponseFromNativeError, + InvalidInputError, + NotFoundError, + ServiceUnavailableError, +} = require("@aliceo2/web-ui"); +const { errorLogger } = require("../utils.js"); +const { getConsulConfig } = require("../config/publicConfigProvider.js"); + +/** + * Gateway for all Consul Consumer calls + */ +class QCConfigurationController { + /** + * Setup QCConfigurationController + * @param {QCConfigurationService} qcConfigurationService - service for managing QC configurations + * @param {JSON} config - consul configuration + */ + constructor(qcConfigurationService, config) { + this._qcConfigurationService = qcConfigurationService; + this._config = getConsulConfig({ consul: config }); + this._qcConfigurationsPath = `${this._config.qcPath}/ANY/any`; + + this._logger = LogManager.getLogger(`${process.env.npm_config_log_label ?? "cnf"}/qc-configuration-controller`); + } + + /** + * Method to get configurations names + * @param {Request} req - HTTP Request object + * @param {Response} res - HTTP Response object + */ + async getConfigurationsKeysHandler(req, res) { + const { prefix = "", recurse = false } = req.query; + const prefixPath = prefix ? `${this._qcConfigurationsPath}/${prefix}` : this._qcConfigurationsPath; + + try { + const validKeys = await this._qcConfigurationService.retrieveKeysOfValidConfigurations(prefixPath, recurse); + + if (!validKeys || validKeys.length === 0) { + updateAndSendExpressResponseFromNativeError(res, new NotFoundError("No valid configurations found")); + return; + } + + res.status(200).json(validKeys); + } catch (error) { + errorLogger(error, this._logger); + if (error.message?.includes('Non-2xx status code: 404')) { + updateAndSendExpressResponseFromNativeError(res, + new NotFoundError(`Configurations prefix not found: "${prefixPath}"`)); + } else { + updateAndSendExpressResponseFromNativeError(res, new ServiceUnavailableError("Consul service unavailable")); + } + } + } + + /** + * Method to get configuration value by key + * @param {Request} req - HTTP Request object + * @param {Response} res - HTTP Response object + */ + async getConfigurationByKeyHandler(req, res) { + const { key } = req.params; + + if (!key || key.trim() === "") { + updateAndSendExpressResponseFromNativeError(res, new InvalidInputError("Missing configuration key")); + return; + } + + try { + const value = await this._qcConfigurationService.retrieveConfigurationByKey(key); + res.status(200).json(value); + } catch (error) { + errorLogger(error, this._logger); + if (error.message?.includes('Non-2xx status code: 404')) { + updateAndSendExpressResponseFromNativeError(res, new NotFoundError(`Configuration not found for key: ${key}`)); + } else { + updateAndSendExpressResponseFromNativeError(res, new ServiceUnavailableError("Consul service unavailable")); + } + } + } +} + +exports.QCConfigurationController = QCConfigurationController; diff --git a/Control/lib/middleware/validateConsulServiceMiddlewareFactory.js b/Control/lib/middleware/validateConsulServiceMiddlewareFactory.js new file mode 100644 index 000000000..1b12f0c90 --- /dev/null +++ b/Control/lib/middleware/validateConsulServiceMiddlewareFactory.js @@ -0,0 +1,39 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const { updateAndSendExpressResponseFromNativeError, ServiceUnavailableError } = require("@aliceo2/web-ui"); + +/** + * Factory function to check if consul service is available + * + * @param {ConsulService} consulService - service for which availability is checked + * @returns {function(req, res, next): void} - middleware function + */ +const validateConsulServiceMiddlewareFactory = (consulService) => { + /** + * Middleware function to check if consul service is available + * @param {Request} req - HTTP Request object + * @param {Response} res - HTTP Response object + * @param {Next} next - HTTP Next object to use if checks pass + * @returns {void} continue if checks pass, uses response object to respond with error if checks fail + */ + return async (req, res, next) => { + if (consulService) { + next(); + } else { + updateAndSendExpressResponseFromNativeError(res, new ServiceUnavailableError("Consul service is not available")); + } + }; +}; + +exports.validateConsulServiceMiddlewareFactory = validateConsulServiceMiddlewareFactory; diff --git a/Control/lib/services/QCConfiguration.service.js b/Control/lib/services/QCConfiguration.service.js new file mode 100644 index 000000000..3c860a962 --- /dev/null +++ b/Control/lib/services/QCConfiguration.service.js @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const { LogManager } = require("@aliceo2/web-ui"); + +/** + * @class + * QCConfigurationService class to be user for communicating with the Consul service + */ +class QCConfigurationService { + /** + * @constructor + * Constructor for configuring the initial state of stored information + * @param {ConsulService} consulService - service to communicate with Consul + */ + constructor(consulService) { + /** + * @type {ConsulService} + */ + this._consulService = consulService; + + this._logger = LogManager.getLogger(`${process.env.npm_config_log_label ?? "cnf"}/qc-configuration-service`); + } + + /** + * Get keys of configurations stored in Consul + * @param {String} prefix - prefix to filter the keys + * @param {boolean} [recurse=false] - whether to recurse into subdirectories + */ + async retrieveKeysOfValidConfigurations(prefix, recurse = false) { + const data = await this._consulService.getOnlyRawValuesByKeyPrefix(prefix); + return this.filterConfigurations(data, recurse, prefix); + } + + /** + * Get configuration by key from Consul + * @param {string} key - the key of the configuration + */ + async retrieveConfigurationByKey(key) { + return await this._consulService.getOnlyRawValueByKey(key); + } + + + /** + * Filters a configuration object and returns keys of entries with valid JSON values. + * @param {object} configs - an object with string values to be checked. + * @param {boolean} recurse - whether to recurse into subdirectories + * @param {string} prefix - the prefix to filter keys + */ + filterConfigurations(configs, recurse, prefix) { + const parsedData = []; + Object.entries(configs || {}).forEach(([key, value]) => { + try { + if (!recurse && key.replace(`${prefix}/`, "").includes("/")) { + return; + } + + const parsedValue = JSON.parse(value); + + if (typeof parsedValue === 'object' && parsedValue !== null && !Array.isArray(parsedValue)) { + parsedData.push(key); + } + } catch (e) { + // skip + } + }); + + return parsedData; + } +} + +exports.QCConfigurationService = QCConfigurationService; diff --git a/Control/test/api/configuration/api-get-configuration.test.js b/Control/test/api/configuration/api-get-configuration.test.js new file mode 100644 index 000000000..599dcbbd7 --- /dev/null +++ b/Control/test/api/configuration/api-get-configuration.test.js @@ -0,0 +1,77 @@ + +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. +*/ + +const request = require('supertest'); +const { ADMIN_TEST_TOKEN, TEST_URL } = require('../generateToken.js'); + +describe(`'API - GET - /configurations/:key(*)' test suite`, () => { + it('should return 200 with the configuration object for an existing key', async () => { + const expectedBody = { key: "value" }; + await request(`${TEST_URL}/api/configurations`) + .get(`/key1?token=${ADMIN_TEST_TOKEN}`) + .expect(200, expectedBody); + }); + + it('should return 404 when the configuration key does not exist', async () => { + const expectedError = { + message: "Configuration not found for key: nonexistent", + status: 404, + title: "Not Found" + }; + await request(`${TEST_URL}/api/configurations`) + .get(`/nonexistent?token=${ADMIN_TEST_TOKEN}`) + .expect(404, expectedError); + }); + + it('should return 400 when the key parameter is empty', async () => { + const expectedError = { + message: "Missing configuration key", + status: 400, + title: "Invalid Input" + }; + await request(`${TEST_URL}/api/configurations`) + .get(`/%20?token=${ADMIN_TEST_TOKEN}`) + .expect(400, expectedError); + }); + + it('should return 503 when Consul fails to respond', async () => { + const expectedError = { + message: "Consul service unavailable", + status: 503, + title: "Service Unavailable" + }; + await request(`${TEST_URL}/api/configurations`) + .get(`/consul-failure?token=${ADMIN_TEST_TOKEN}`) + .expect(503, expectedError); + }); + + it('should return 403 unauthorized error for missing token requests', async () => { + await request(`${TEST_URL}/api/configurations`) + .get('/key1') + .expect(403, { + error: '403 - Json Web Token Error', + message: 'You must provide a JWT token' + }); + }); + + it('should return 403 unauthorized error for invalid token requests', async () => { + await request(`${TEST_URL}/api/configurations`) + .get('/key1?token=invalid-token') + .expect(403, { + error: '403 - Json Web Token Error', + message: 'Invalid JWT token provided' + }); + }); +}); diff --git a/Control/test/api/configuration/api-get-configurations.test.js b/Control/test/api/configuration/api-get-configurations.test.js new file mode 100644 index 000000000..afea531e9 --- /dev/null +++ b/Control/test/api/configuration/api-get-configurations.test.js @@ -0,0 +1,80 @@ + +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. +*/ + +const request = require('supertest'); +const { ADMIN_TEST_TOKEN, TEST_URL } = require('../generateToken.js'); +const { consul: { qcPath } } = require('../../test-config'); + +describe(`'API - GET - /configurations' test suite`, () => { + it('should return 200 with valid configuration keys', async () => { + await request(`${TEST_URL}/api`) + .get(`/configurations?token=${ADMIN_TEST_TOKEN}`) + .expect(200, ['key1']); + }); + + it('should return 404 when the prefix is valid but contains no keys', async () => { + const prefix = 'empty-prefix'; + const expectedError = { + message: "No valid configurations found", + status: 404, + title: "Not Found" + }; + await request(`${TEST_URL}/api`) + .get(`/configurations?prefix=${prefix}&token=${ADMIN_TEST_TOKEN}`) + .expect(404, expectedError); + }); + + it('should return 404 when the specified prefix does not exist', async () => { + const prefix = 'nonexistent-prefix'; + const expectedError = { + message: `Configurations prefix not found: "${qcPath}/ANY/any/${prefix}"`, + status: 404, + title: "Not Found" + }; + await request(`${TEST_URL}/api`) + .get(`/configurations?prefix=${prefix}&token=${ADMIN_TEST_TOKEN}`) + .expect(404, expectedError); + }); + + it('should return 503 when Consul returns an internal error', async () => { + const prefix = 'server-error-prefix'; + const expectedError = { + message: "Consul service unavailable", + status: 503, + title: "Service Unavailable" + }; + await request(`${TEST_URL}/api`) + .get(`/configurations?prefix=${prefix}&token=${ADMIN_TEST_TOKEN}`) + .expect(503, expectedError); + }); + + it('should return 403 unauthorized error for missing token requests', async () => { + await request(`${TEST_URL}/api`) + .get('/configurations') + .expect(403, { + error: '403 - Json Web Token Error', + message: 'You must provide a JWT token' + }); + }); + + it('should return 403 unauthorized error for invalid token requests', async () => { + await request(`${TEST_URL}/api`) + .get('/configurations?token=invalid-token') + .expect(403, { + error: '403 - Json Web Token Error', + message: 'Invalid JWT token provided' + }); + }); +}); diff --git a/Control/test/config/testConfigForConsul.js b/Control/test/config/testConfigForConsul.js new file mode 100644 index 000000000..44572fe04 --- /dev/null +++ b/Control/test/config/testConfigForConsul.js @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. +*/ + +const nock = require('nock'); +const config = require('../test-config'); + +const CONSUL_URL = `http://${config.consul.hostname}:${config.consul.port}`; +const KV_PATH = '/v1/kv/'; + +/** + * Setup nock environment to intercept requests to the Consul API. + */ +const initializeNockForConsul = () => { + nock(CONSUL_URL) + .persist() + .get('/v1/status/leader') + .reply(200, 'http://localhost:8550'); + + // /configurations + nock(CONSUL_URL) + .persist() + .get(`${KV_PATH}${config.consul.qcPath}/ANY/any?recurse=true`) + .reply(200, JSON.stringify([ + { + LockIndex: 0, + Key: "key1", + Flags: 0, + Value: Buffer.from(JSON.stringify({key1: "value1"})).toString('base64'), + CreateIndex: 1, + ModifyIndex: 1 + } + ])) + + nock(CONSUL_URL) + .persist() + .get(`${KV_PATH}${config.consul.qcPath}/ANY/any/empty-prefix?recurse=true`) + .reply(200, JSON.stringify([ + { + LockIndex: 0, + Key: "empty-prefix", + Flags: 0, + Value: null, + CreateIndex: 1, + ModifyIndex: 1 + } + ])) + + nock(CONSUL_URL) + .persist() + .get(`${KV_PATH}${config.consul.qcPath}/ANY/any/nonexistent-prefix?recurse=true`) + .reply(404) + + nock(CONSUL_URL) + .persist() + .get(`${KV_PATH}${config.consul.qcPath}/ANY/any/server-error-prefix?recurse=true`) + .reply(503) + + // /configurations/:key(*) + nock(CONSUL_URL) + .persist() + .get(`${KV_PATH}key1?raw=true`) + .reply(200, JSON.stringify({key: "value"})) + + nock(CONSUL_URL) + .persist() + .get(`${KV_PATH}nonexistent?raw=true`) + .reply(404) + + nock(CONSUL_URL) + .persist() + .get(`${KV_PATH}consul-failure?raw=true`) + .reply(503) +} + +module.exports = { + initializeNockForConsul +}; diff --git a/Control/test/lib/controllers/mocha-consul-controller.js b/Control/test/lib/controllers/mocha-consul-controller.js index 762bb1324..4f3c8bfb3 100644 --- a/Control/test/lib/controllers/mocha-consul-controller.js +++ b/Control/test/lib/controllers/mocha-consul-controller.js @@ -63,37 +63,6 @@ describe('ConsulController test suite', () => { }); }); - describe('Test Consul Connection', async () => { - let consulService; - beforeEach(() => consulService = {}); - it('should successfully query host of ConsulLeader', async () => { - consulService.getConsulLeaderStatus = sinon.stub().resolves('localhost:8550'); - const connector = new ConsulController(consulService, config); - await connector.testConsulStatus(); - }); - it('should successfully query host of ConsulLeader and fail gracefully', async () => { - consulService.getConsulLeaderStatus = sinon.stub().rejects('Unable to query Consul'); - const connector = new ConsulController(consulService, config); - await connector.testConsulStatus(); - }); - it('should successfully validate connector if consulService is present', () => { - const connector = new ConsulController(consulService, config); - const next = sinon.stub(); - connector.validateService({}, {}, next); - assert.ok(next.calledWith()); - }); - it('should successfully respond with error on connector validate if consulService is missing', () => { - const connector = new ConsulController(undefined, config); - const res = { - status: sinon.stub(), - send: sinon.stub(), - }; - connector.validateService({}, res, {}); - assert.ok(res.status.calledWith(502)); - assert.ok(res.send.calledWith({message: 'Unable to retrieve configuration of consul service'})); - }); - }); - describe('Request CRUs tests', async () => { let consulService; beforeEach(() => { diff --git a/Control/test/lib/controllers/mocha-qcConfiguration.controller.test.js b/Control/test/lib/controllers/mocha-qcConfiguration.controller.test.js new file mode 100644 index 000000000..9d66ace7b --- /dev/null +++ b/Control/test/lib/controllers/mocha-qcConfiguration.controller.test.js @@ -0,0 +1,171 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. +*/ + +const assert = require('assert'); +const sinon = require('sinon'); + +const { QCConfigurationController } = require('../../../lib/controllers/QCConfiguration.controller.js'); +const { QCConfigurationService } = require('../../../lib/services/QCConfiguration.service.js'); + +describe(`'QCConfigurationController' test suite`, () => { + let qcConfigurationService, qcConfigurationController, req, res, statusStub, jsonStub; + + beforeEach(() => { + qcConfigurationService = new QCConfigurationService({}); + qcConfigurationService.retrieveKeysOfValidConfigurations = sinon.stub(); + qcConfigurationService.retrieveConfigurationByKey = sinon.stub(); + + qcConfigurationController = new QCConfigurationController(qcConfigurationService, { consul: { qcPath: 'o2/components/qc' } }); + + jsonStub = sinon.stub(); + statusStub = sinon.stub().returns({ json: jsonStub }); + res = { status: statusStub }; + req = { query: {}, params: {} }; + }); + + afterEach(() => { + sinon.restore(); + }); + + describe(`'getConfigurationsKeysHandler' test suite`, () => { + it('should return 200 with keys when valid configurations are found (no prefix)', async () => { + const keys = ['o2/components/qc/ANY/any/prefix1', 'o2/components/qc/ANY/any/prefix2']; + qcConfigurationService.retrieveKeysOfValidConfigurations.resolves(keys); + + await qcConfigurationController.getConfigurationsKeysHandler(req, res); + + assert.ok(statusStub.calledWith(200)); + assert.deepStrictEqual(jsonStub.firstCall.args[0], keys); + assert.ok(qcConfigurationService.retrieveKeysOfValidConfigurations.calledWith('o2/components/qc/ANY/any', false)); + }); + + it('should return 200 with keys when a prefix is provided', async () => { + const keys = ['o2/components/qc/ANY/any/dir1/prefix1']; + qcConfigurationService.retrieveKeysOfValidConfigurations.resolves(keys); + req.query.prefix = 'dir1'; + + await qcConfigurationController.getConfigurationsKeysHandler(req, res); + + assert.ok(statusStub.calledWith(200)); + assert.deepStrictEqual(jsonStub.firstCall.args[0], keys); + assert.ok(qcConfigurationService.retrieveKeysOfValidConfigurations.calledWith('o2/components/qc/ANY/any/dir1', false)); + }); + + it('should return 200 with keys when recurse is true', async () => { + const keys = ['o2/components/qc/ANY/any/dir1/prefix1', 'o2/components/qc/ANY/any/prefix2']; + qcConfigurationService.retrieveKeysOfValidConfigurations.resolves(keys); + req.query.recurse = true; + req.query.prefix = 'dir1'; + + await qcConfigurationController.getConfigurationsKeysHandler(req, res); + + assert.ok(statusStub.calledWith(200)); + assert.deepStrictEqual(jsonStub.firstCall.args[0], keys); + assert.ok(qcConfigurationService.retrieveKeysOfValidConfigurations.calledWith('o2/components/qc/ANY/any/dir1', true)); + }); + + it('should return 404 when service returns an empty array', async () => { + qcConfigurationService.retrieveKeysOfValidConfigurations.resolves([]); + + await qcConfigurationController.getConfigurationsKeysHandler(req, res); + + assert.ok(statusStub.calledWith(404)); + assert.deepStrictEqual(jsonStub.firstCall.args[0].message, 'No valid configurations found'); + }); + + it('should return 404 when service returns null', async () => { + qcConfigurationService.retrieveKeysOfValidConfigurations.resolves(null); + + await qcConfigurationController.getConfigurationsKeysHandler(req, res); + + assert.ok(statusStub.calledWith(404)); + assert.deepStrictEqual(jsonStub.firstCall.args[0].message, 'No valid configurations found'); + }); + + it('should return 404 when service throws a "404" error for a non-existent prefix', async () => { + const prefix = 'nonexistent'; + req.query.prefix = prefix; + const expectedPath = `o2/components/qc/ANY/any/${prefix}`; + qcConfigurationService.retrieveKeysOfValidConfigurations.rejects(new Error('Non-2xx status code: 404')); + + await qcConfigurationController.getConfigurationsKeysHandler(req, res); + + assert.ok(statusStub.calledWith(404)); + assert.deepStrictEqual(jsonStub.firstCall.args[0].message, `Configurations prefix not found: "${expectedPath}"`); + }); + + it('should return 503 when service throws a service unavailable error', async () => { + qcConfigurationService.retrieveKeysOfValidConfigurations.rejects(new Error('Consul not working')); + + await qcConfigurationController.getConfigurationsKeysHandler(req, res); + + assert.ok(statusStub.calledWith(503)); + assert.deepStrictEqual(jsonStub.firstCall.args[0].message, 'Consul service unavailable'); + }); + }); + + describe(`'getConfigurationByKeyHandler' test suite`, () => { + it('should return 200 with configuration for a valid key', async () => { + const config = { key1: 'value1' }; + const configKey = 'o2/qc/path/config1'; + req.params.key = configKey; + qcConfigurationService.retrieveConfigurationByKey.resolves(config); + + await qcConfigurationController.getConfigurationByKeyHandler(req, res); + + assert.ok(qcConfigurationService.retrieveConfigurationByKey.calledWith(configKey)); + assert.ok(statusStub.calledWith(200)); + assert.deepStrictEqual(jsonStub.firstCall.args[0], config); + }); + + it('should return 400 if key is missing', async () => { + req.params.key = undefined; + + await qcConfigurationController.getConfigurationByKeyHandler(req, res); + + assert.ok(statusStub.calledWith(400)); + assert.deepStrictEqual(jsonStub.firstCall.args[0].message, 'Missing configuration key'); + }); + + it('should return 400 if key is an empty string', async () => { + req.params.key = ' '; + + await qcConfigurationController.getConfigurationByKeyHandler(req, res); + + assert.ok(statusStub.calledWith(400)); + assert.deepStrictEqual(jsonStub.firstCall.args[0].message, 'Missing configuration key'); + }); + + it('should return 404 when service throws a "404" error for a non-existent key', async () => { + const nonExistentKey = 'non-existent-key'; + req.params.key = nonExistentKey; + qcConfigurationService.retrieveConfigurationByKey.rejects(new Error('Non-2xx status code: 404')); + + await qcConfigurationController.getConfigurationByKeyHandler(req, res); + + assert.ok(statusStub.calledWith(404)); + assert.deepStrictEqual(jsonStub.firstCall.args[0].message, `Configuration not found for key: ${nonExistentKey}`); + }); + + it('should return 503 when service throws a service unavailable error', async () => { + req.params.key = 'some-key'; + qcConfigurationService.retrieveConfigurationByKey.rejects(new Error('Consul not working')); + + await qcConfigurationController.getConfigurationByKeyHandler(req, res); + + assert.ok(statusStub.calledWith(503)); + assert.deepStrictEqual(jsonStub.firstCall.args[0].message, 'Consul service unavailable'); + }); + }); +}); diff --git a/Control/test/lib/middleware/mocha-validateConsulServiceMiddlewareFactory.test.js b/Control/test/lib/middleware/mocha-validateConsulServiceMiddlewareFactory.test.js new file mode 100644 index 000000000..1b2ddc542 --- /dev/null +++ b/Control/test/lib/middleware/mocha-validateConsulServiceMiddlewareFactory.test.js @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. +*/ + +const assert = require('assert'); +const sinon = require('sinon'); +const { validateConsulServiceMiddlewareFactory } = require('../../../lib/middleware/validateConsulServiceMiddlewareFactory.js'); + +describe('`validateConsulServiceMiddlewareFactory` test suite', () => { + let consulService, reqMock, resMock, nextMock; + + beforeEach(() => { + consulService = {}; + + reqMock = {}; + + resMock = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + + nextMock = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should call next() consulService is ok', async () => { + await validateConsulServiceMiddlewareFactory(consulService)(reqMock, resMock, nextMock); + + assert.ok(nextMock.calledOnce); + assert.ok(resMock.status.notCalled); + assert.ok(resMock.json.notCalled); + }); + + it('should return 503 if there consulService is not available', async () => { + consulService = null; + await validateConsulServiceMiddlewareFactory(consulService)(reqMock, resMock, nextMock); + + assert.ok(resMock.status.calledOnceWith(503)); + assert.ok(resMock.json.calledOnceWith( + sinon.match({ message: "Consul service is not available" }) + )); + assert.ok(nextMock.notCalled); + }); +}); diff --git a/Control/test/lib/services/mocha-qcConfiguration.service.test.js b/Control/test/lib/services/mocha-qcConfiguration.service.test.js new file mode 100644 index 000000000..a1191376c --- /dev/null +++ b/Control/test/lib/services/mocha-qcConfiguration.service.test.js @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ +/* eslint-disable max-len */ + +const assert = require("assert"); +const sinon = require("sinon"); + +const { QCConfigurationService } = require("../../../lib/services/QCConfiguration.service.js"); + +describe(`'QCConfigurationService' test suite`, () => { + let consulServiceStub, qcConfigurationService; + + beforeEach(() => { + consulServiceStub = { + getOnlyRawValuesByKeyPrefix: sinon.stub(), + getOnlyRawValueByKey: sinon.stub(), + }; + qcConfigurationService = new QCConfigurationService(consulServiceStub); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe(`'retrieveKeysOfValidConfigurations' test suite`, () => { + it("should return keys of valid configurations only in the root of the prefix (recurse=false)", async () => { + const prefix = "any"; + const rawData = { + "any/dir1/nested": '{"key": "value"}', + "any/valid1": '{"key1": "value1"}', + "any/invalid_json": '"key1": "value1"', + "any/valid2": '{}', + }; + consulServiceStub.getOnlyRawValuesByKeyPrefix.resolves(rawData); + + const configurations = await qcConfigurationService.retrieveKeysOfValidConfigurations(prefix, false); + + assert.ok(consulServiceStub.getOnlyRawValuesByKeyPrefix.calledOnceWith(prefix)); + assert.deepStrictEqual(configurations, ["any/valid1", "any/valid2"]); + }); + + it("should return keys of all valid configurations when recurse is true", async () => { + const prefix = "any"; + const rawData = { + "any/dir1/nested": '{"key": "value"}', + "any/valid1": '{"key1": "value1"}', + "any/dir1/invalid": 'just string', + }; + consulServiceStub.getOnlyRawValuesByKeyPrefix.resolves(rawData); + + const configurations = await qcConfigurationService.retrieveKeysOfValidConfigurations(prefix, true); + + assert.ok(consulServiceStub.getOnlyRawValuesByKeyPrefix.calledOnceWith(prefix)); + assert.deepStrictEqual(configurations, ["any/dir1/nested", "any/valid1"]); + }); + + it("should return an empty array when consul service returns no data", async () => { + const prefix = "nonexistent"; + consulServiceStub.getOnlyRawValuesByKeyPrefix.resolves({}); + + const configurations = await qcConfigurationService.retrieveKeysOfValidConfigurations(prefix); + + assert.ok(consulServiceStub.getOnlyRawValuesByKeyPrefix.calledOnceWith(prefix)); + assert.deepStrictEqual(configurations, []); + }); + + it("should propagate errors from the consul service", async () => { + const testError = new Error("Consul not working"); + consulServiceStub.getOnlyRawValuesByKeyPrefix.rejects(testError); + + await assert.rejects( + async () => await qcConfigurationService.retrieveKeysOfValidConfigurations("any"), + testError + ); + }); + }); + + describe(`'retrieveConfigurationByKey' test suite`, () => { + it("should return configuration for a valid key", async () => { + const key = "any/prefix1"; + const expectedConfig = { key1: "value1", key2: "value2" }; + consulServiceStub.getOnlyRawValueByKey.resolves(expectedConfig); + + const configuration = await qcConfigurationService.retrieveConfigurationByKey(key); + + assert.ok(consulServiceStub.getOnlyRawValueByKey.calledOnceWith(key)); + assert.deepStrictEqual(configuration, expectedConfig); + }); + + it("should propagate errors from the consul service", async () => { + const testError = new Error("Consul not working"); + consulServiceStub.getOnlyRawValueByKey.rejects(testError); + + await assert.rejects( + async () => await qcConfigurationService.retrieveConfigurationByKey("any"), + testError + ); + }); + }); + + describe('`filterConfigurations` test suite', () => { + it('should return keys of valid JSON objects and ignore others when recurse is false', () => { + const configs = { + 'any/valid_object': '{"key": "value"}', + 'any/empty_object': '{}', + 'any/nested/key': '{"a": 1}', + 'any/not_a_json': 'just a plain string', + 'any/malformed_json': '{"key":', + 'any/json_string': '"a valid json string"', + 'any/json_array': '[1, 2, 3]', + 'any/json_null': 'null', + }; + const expectedKeys = ['any/valid_object', 'any/empty_object']; + + const result = qcConfigurationService.filterConfigurations(configs, false, 'any'); + assert.deepStrictEqual(result, expectedKeys); + }); + + it('should include nested keys when recurse is true', () => { + const configs = { + 'any/valid_object': '{"key": "value"}', + 'any/nested/key': '{"a": 1}', + 'any/nested/invalid': 'not json', + }; + const expectedKeys = ['any/valid_object', 'any/nested/key']; + + const result = qcConfigurationService.filterConfigurations(configs, true, 'any'); + assert.deepStrictEqual(result, expectedKeys); + }); + + it('should return an empty array for null, undefined and empty object input', () => { + assert.deepStrictEqual(qcConfigurationService.filterConfigurations(null, false, 'any'), []); + assert.deepStrictEqual(qcConfigurationService.filterConfigurations(undefined, false, 'any'), []); + assert.deepStrictEqual(qcConfigurationService.filterConfigurations({}, false, 'any'), []); + }); + }); +}); diff --git a/Control/test/mocha-index.js b/Control/test/mocha-index.js index 0cd2bdd18..8415e0c0e 100644 --- a/Control/test/mocha-index.js +++ b/Control/test/mocha-index.js @@ -48,7 +48,8 @@ describe('Control', function() { const {calls: apricotCalls} = apricotGRPCServer(config); // Start web-server in background - subprocess = spawn('node', ['index.js', 'test/test-config.js'], {stdio: 'pipe'}); + subprocess = spawn('node', ['index.js', 'test/test-config.js'], + {stdio: 'pipe', env: {...process.env, NODE_ENV: 'test'}}); subprocess.stdout.on('data', (chunk) => { subprocessOutput += chunk.toString(); }); @@ -177,6 +178,9 @@ describe('Control', function() { require('./api/tasks/api-get-tasks.test'); require('./api/tasks/api-delete-tasks-test'); + require('./api/configuration/api-get-configurations.test'); + require('./api/configuration/api-get-configuration.test'); + beforeEach(() => this.ok = true); afterEach(() => {