diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 72753381..26591b10 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -32,7 +32,7 @@ }, "devDependencies": { "env-cmd": "^10.1.0", - "eslint": "^9.11.0", + "eslint": "^9.11.1", "jest": "^29.7.0", "jest-sonar-reporter": "^2.0.0", "node-notifier": "^10.0.1", @@ -664,6 +664,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/core": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.6.0.tgz", + "integrity": "sha512-8I2Q8ykA4J0x0o7cg67FPVnehcqWTBehu/lmY+bolPFHGjh49YzGBMXTvpqVgEbBdvNCSxj6iFgiIyHzf03lzg==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/eslintrc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", @@ -688,9 +697,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.11.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.11.0.tgz", - "integrity": "sha512-LPkkenkDqyzTFauZLLAPhIb48fj6drrfMvRGSL9tS3AcZBSVTllemLSNyCvHNNL2t797S/6DJNSIwRwXgMO/eQ==", + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.11.1.tgz", + "integrity": "sha512-/qu+TWz8WwPWc7/HcIJKi+c+MOm46GdVaSlTTQcaqaL53+GsoA6MxWp5PtTx48qbSP7ylM1Kn7nhvkugfJvRSA==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1311,6 +1320,12 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -1344,10 +1359,16 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, "node_modules/@types/node": { - "version": "22.5.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.5.tgz", - "integrity": "sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==", + "version": "22.6.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.6.1.tgz", + "integrity": "sha512-V48tCfcKb/e6cVUigLAaJDAILdMP0fUW6BidkPK4GpGjXcfbnoHasCZDwz3N3yVt5we2RHm4XTQCpv0KJz9zqw==", "dev": true, "dependencies": { "undici-types": "~6.19.2" @@ -1930,9 +1951,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001662", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001662.tgz", - "integrity": "sha512-sgMUVwLmGseH8ZIrm1d51UbrhqMCH3jvS7gF/M6byuHOnKyLOBL7W8yz5V02OHwgLGA36o/AFhWzzh4uc5aqTA==", + "version": "1.0.30001663", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001663.tgz", + "integrity": "sha512-o9C3X27GLKbLeTYZ6HBOLU1tsAcBZsLis28wrVzddShCS16RujjHp9GDHKZqrB3meE0YjhawvMFsGb/igqiPzA==", "dev": true, "funding": [ { @@ -2468,20 +2489,23 @@ } }, "node_modules/eslint": { - "version": "9.11.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.11.0.tgz", - "integrity": "sha512-yVS6XODx+tMFMDFcG4+Hlh+qG7RM6cCJXtQhCKLSsr3XkLvWggHjCqjfh0XsPPnt1c56oaT6PMgW9XWQQjdHXA==", + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.11.1.tgz", + "integrity": "sha512-MobhYKIoAO1s1e4VUrgx1l1Sk2JBR/Gqjjgw8+mfgoLE2xwsHur4gdfTxyTgShrhvdVFTaJSgMiQBl1jv/AWxg==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", "@eslint/config-array": "^0.18.0", + "@eslint/core": "^0.6.0", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.11.0", + "@eslint/js": "9.11.1", "@eslint/plugin-kit": "^0.2.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", diff --git a/package.json b/package.json index 891079a8..6e15c58c 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ }, "devDependencies": { "env-cmd": "^10.1.0", - "eslint": "^9.11.0", + "eslint": "^9.11.1", "jest": "^29.7.0", "jest-sonar-reporter": "^2.0.0", "node-notifier": "^10.0.1", diff --git a/src/middleware/gitops.js b/src/middleware/gitops.js new file mode 100644 index 00000000..f4389c46 --- /dev/null +++ b/src/middleware/gitops.js @@ -0,0 +1,61 @@ +import { responseException } from '../exceptions'; +import { checkGitopsIntegration, SwitcherKeys } from '../external/switcher-api-facade'; + +const PATH_CONSTRAINTS_NEW = { + GROUP: 0, + CONFIG: 1, + STRATEGY: 2, + COMPONENT: 2, + STRATEGY_VALUE: 3 +}; + +export async function featureFlag(req, res, next) { + try { + await checkGitopsIntegration(req.domain); + next(); + } catch (e) { + responseException(res, e, 400, SwitcherKeys.GITOPS_INTEGRATION); + } +}; + +export function validateChanges(req, res, next) { + try { + req.body = req.body || {}; + const changes = req.body.changes; + + validatePathForElement(changes); + validateChangesContent(changes); + next(); + } catch (e) { + res.status(422).send({ + errors: [{ + msg: e.message, + location: 'body' + }] + }); + } +} + +function validatePathForElement(changes) { + for (const change of changes) { + if (change.action === 'NEW') { + const path = change.path; + const diff = change.diff; + if (path.length !== PATH_CONSTRAINTS_NEW[diff]) { + throw new Error('Request has invalid path settings for new element'); + } + } + } +} + +function validateChangesContent(changes) { + for (const change of changes) { + if (['COMPONENT', 'STRATEGY_VALUE'].includes(change.diff)) { + if (!Array.isArray(change.content)) { + throw new Error('Request has invalid content type [object]'); + } + } else if (Array.isArray(change.content)) { + throw new Error('Request has invalid content type [array]'); + } + } +} \ No newline at end of file diff --git a/src/routers/gitops.js b/src/routers/gitops.js index 8723357c..86c2b05a 100644 --- a/src/routers/gitops.js +++ b/src/routers/gitops.js @@ -1,32 +1,26 @@ import express from 'express'; -import { check } from 'express-validator'; +import { body } from 'express-validator'; import { responseException } from '../exceptions/index.js'; import { gitopsAuth } from '../middleware/auth.js'; import { validate } from '../middleware/validators.js'; -import { checkGitopsIntegration, SwitcherKeys } from '../external/switcher-api-facade.js'; -import * as Service from '../services/gitops.js'; +import { featureFlag, validateChanges } from '../middleware/gitops.js'; +import * as Service from '../services/gitops/index.js'; const router = new express.Router(); -const featureFlagMiddleware = async (req, res, next) => { - try { - await checkGitopsIntegration(req.domain); - next(); - } catch (e) { - responseException(res, e, 400, SwitcherKeys.GITOPS_INTEGRATION); - } -}; - -router.post('/gitops/v1/push', featureFlagMiddleware, gitopsAuth, [ - check('changes').exists(), -], validate, async (req, res) => { +router.post('/gitops/v1/push', featureFlag, gitopsAuth, [ + body('environment').isString(), + body('changes').isArray(), + body('changes.*.path').isArray({ min: 0, max: 3 }), + body('changes.*.action') + .custom(value => ['NEW', 'CHANGED', 'DELETED'].includes(value)) + .withMessage('Request has invalid type of action'), + body('changes.*.diff') + .custom(value => ['GROUP', 'CONFIG', 'STRATEGY', 'STRATEGY_VALUE', 'COMPONENT'].includes(value)) + .withMessage('Request has invalid type of diff'), +], validate, validateChanges, async (req, res) => { try { const result = await Service.pushChanges(req.domain, req.body.environment, req.body.changes); - - if (!result.valid) { - return res.status(400).send(result); - } - res.status(200).send(result); } catch (e) { responseException(res, e, 500); diff --git a/src/services/gitops/index.js b/src/services/gitops/index.js new file mode 100644 index 00000000..23eaa746 --- /dev/null +++ b/src/services/gitops/index.js @@ -0,0 +1,23 @@ +import { getDomainById, updateDomainVersion } from '../domain.js'; +import { processNew } from './push-new.js'; + +export const ADMIN_EMAIL = 'gitops@admin.noreply.switcherapi.com'; + +export async function pushChanges(domainId, environment, changes) { + let domain = await getDomainById(domainId); + for (const change of changes) { + if (change.action === 'NEW') { + await processNew(domain, change, environment); + } + }; + + domain = await updateDomainVersion(domainId); + return successResponse('Changes applied successfully', domain.lastUpdate); +} + +function successResponse(message, version) { + return { + message, + version + }; +} \ No newline at end of file diff --git a/src/services/gitops.js b/src/services/gitops/push-new.js similarity index 57% rename from src/services/gitops.js rename to src/services/gitops/push-new.js index 5e178dc0..2c82e6d9 100644 --- a/src/services/gitops.js +++ b/src/services/gitops/push-new.js @@ -1,76 +1,10 @@ -import { getComponents } from './component.js'; -import { createStrategy, getStrategies, updateStrategy } from './config-strategy.js'; -import { createConfig, getConfig } from './config.js'; -import { getDomainById, updateDomainVersion } from './domain.js'; -import { createGroup, getGroupConfig } from './group-config.js'; +import { getComponents } from '../component.js'; +import { createStrategy, getStrategies, updateStrategy } from '../config-strategy.js'; +import { addComponent, createConfig, getConfig } from '../config.js'; +import { createGroup, getGroupConfig } from '../group-config.js'; +import { ADMIN_EMAIL } from './index.js'; -const PATH_CONSTRAINTS_NEW = { - GROUP: 0, - CONFIG: 1, - STRATEGY: 2, - STRATEGY_VALUE: 3 -}; - -export async function pushChanges(domainId, environment, changes) { - const validations = validateChanges(changes); - if (validations) { - return errorResponse(validations); - } - - let domain = await getDomainById(domainId); - for (const change of changes) { - if (change.action === 'NEW') { - await processNew(domain, change, environment); - } - }; - - domain = await updateDomainVersion(domainId); - return successResponse('Changes applied successfully', domain.lastUpdate); -} - -function validateChanges(changes) { - try { - validateActions(changes); - validateDiff(changes); - validatePathForElement(changes); - } catch (e) { - return e.message; - } - - return undefined; -} - -function validateActions(changes) { - const validActions = ['NEW', 'CHANGED', 'DELETED']; - const hasInvalidAction = changes.some(change => !validActions.includes(change.action)); - - if (hasInvalidAction) { - throw new Error('Request has invalid type of change'); - } -} - -function validateDiff(changes) { - const validDiff = ['GROUP', 'CONFIG', 'STRATEGY', 'STRATEGY_VALUE']; - const hasInvalidDiff = changes.some(change => !validDiff.includes(change.diff)); - - if (hasInvalidDiff) { - throw new Error('Request has invalid type of diff'); - } -} - -function validatePathForElement(changes) { - for (const change of changes) { - if (change.action === 'NEW') { - const path = change.path; - const diff = change.diff; - if (path.length !== PATH_CONSTRAINTS_NEW[diff]) { - throw new Error('Request has invalid path settings for new element'); - } - } - } -} - -async function processNew(domain, change, environment) { +export async function processNew(domain, change, environment) { switch (change.diff) { case 'GROUP': await processNewGroup(domain, change, environment); @@ -84,6 +18,9 @@ async function processNew(domain, change, environment) { case 'STRATEGY_VALUE': await processNewStrategyValue(domain, change); break; + case 'COMPONENT': + await processNewComponent(domain, change); + break; } } @@ -160,7 +97,7 @@ async function processNewStrategy(domain, change, environment) { async function processNewStrategyValue(domain, change) { const path = change.path; const content = change.content; - const admin = { _id: domain.owner, email: 'gitops@admin.noreply.switcherapi.com' }; + const admin = { _id: domain.owner, email: ADMIN_EMAIL }; const config = await getConfig({ domain: domain._id, key: path[1] }); const strategies = await getStrategies({ config: config._id }); @@ -171,17 +108,18 @@ async function processNewStrategyValue(domain, change) { }, admin); } -function successResponse(message, version) { - return { - valid: true, - message, - version - }; -} +async function processNewComponent(domain, change) { + const path = change.path; + const content = change.content; + const admin = { _id: domain.owner, email: ADMIN_EMAIL }; + const config = await getConfig({ domain: domain._id, key: path[1] }); -function errorResponse(message) { - return { - valid: false, - message - }; + const components = await getComponents({ domain: domain._id, name: { $in: content } }); + const componentIds = components.map(component => component._id); + + for (const id of componentIds) { + if (!config.components.includes(id)) { + await addComponent(config._id, { component: id }, admin); + } + } } \ No newline at end of file diff --git a/tests/gitops.test.js b/tests/gitops.test.js index 4d995f09..8775f62c 100644 --- a/tests/gitops.test.js +++ b/tests/gitops.test.js @@ -318,9 +318,36 @@ describe('GitOps - Push New Changes', () => { values: ['USER_1', 'USER_2', 'USER_3', 'USER_4'] }); }); + + test('GITOPS_SUITE - Should push changes - New Component', async () => { + const token = generateToken('30s'); + + const lastUpdate = Date.now(); + const req = await request(app) + .post('/gitops/v1/push') + .set('Authorization', `Bearer ${token}`) + .send({ + environment: EnvType.DEFAULT, + changes: [{ + action: 'NEW', + diff: 'COMPONENT', + path: ['Group Test', 'TEST_CONFIG_KEY_PRD_QA'], + content: ['TestApp'] + }] + }) + .expect(200); + + expect(req.body.message).toBe('Changes applied successfully'); + expect(req.body.version).toBeGreaterThan(lastUpdate); + + // Check if the changes were applied + const config = await Config.findOne({ key: 'TEST_CONFIG_KEY_PRD_QA', domain: domainId }).lean().exec(); + expect(config).not.toBeNull(); + expect(config.components).toHaveLength(1); + }); }); -describe('GitOps - Push Changes - Errors', () => { +describe('GitOps - Push Changes - Errors (invalid requests)', () => { beforeAll(setupDatabase); test('GITOPS_SUITE - Should return error when action is invalid', async () => { @@ -332,12 +359,14 @@ describe('GitOps - Push Changes - Errors', () => { .send({ environment: EnvType.DEFAULT, changes: [{ - action: 'INVALID' + action: 'INVALID', + diff: 'STRATEGY', + path: [] }] }) - .expect(400); + .expect(422); - expect(req.body.message).toBe('Request has invalid type of change'); + expect(req.body.errors[0].msg).toBe('Request has invalid type of action'); }); test('GITOPS_SUITE - Should return error when change type is invalid', async () => { @@ -350,15 +379,16 @@ describe('GitOps - Push Changes - Errors', () => { environment: EnvType.DEFAULT, changes: [{ action: 'NEW', - diff: 'INVALID' + diff: 'INVALID', + path: [] }] }) - .expect(400); + .expect(422); - expect(req.body.message).toBe('Request has invalid type of diff'); + expect(req.body.errors[0].msg).toBe('Request has invalid type of diff'); }); - test('GITOPS_SUITE - Should return error when content is malformed', async () => { + test('GITOPS_SUITE - Should return error when change content Array-based is not valid', async () => { const token = generateToken('30s'); const req = await request(app) @@ -368,13 +398,36 @@ describe('GitOps - Push Changes - Errors', () => { environment: EnvType.DEFAULT, changes: [{ action: 'NEW', - diff: 'CONFIG', - path: ['Group Test'], + diff: 'STRATEGY_VALUE', + path: ['Group Test', 'TEST_CONFIG_KEY', StrategiesType.VALUE], + content: { + value: 'SHOULD_BE_ARRAY' + } }] }) - .expect(500); + .expect(422); - expect(req.body.error).not.toBeNull(); + expect(req.body.errors[0].msg).toBe('Request has invalid content type [object]'); + }); + + test('GITOPS_SUITE - Should return error when change content Object-based is not valid', async () => { + const token = generateToken('30s'); + + const req = await request(app) + .post('/gitops/v1/push') + .set('Authorization', `Bearer ${token}`) + .send({ + environment: EnvType.DEFAULT, + changes: [{ + action: 'NEW', + diff: 'STRATEGY', + path: ['Group Test', 'TEST_CONFIG_KEY'], + content: ['SHOULD_BE_OBJECT'] + }] + }) + .expect(422); + + expect(req.body.errors[0].msg).toBe('Request has invalid content type [array]'); }); test('GITOPS_SUITE - Should return error when path is not properly defined - For New', async () => { @@ -395,10 +448,28 @@ describe('GitOps - Push Changes - Errors', () => { } }] }) - .expect(400); + .expect(422); + + expect(req.body.errors[0].msg).toBe('Request has invalid path settings for new element'); + }); + + test('GITOPS_SUITE - Should return error when content is malformed', async () => { + const token = generateToken('30s'); + + const req = await request(app) + .post('/gitops/v1/push') + .set('Authorization', `Bearer ${token}`) + .send({ + environment: EnvType.DEFAULT, + changes: [{ + action: 'NEW', + diff: 'CONFIG', + path: ['Group Test'], + }] + }) + .expect(500); expect(req.body.error).not.toBeNull(); - expect(req.body.message).toBe('Request has invalid path settings for new element'); }); }); \ No newline at end of file