From 209b6be8f2970e5e9c10b405460fcc3ccf6fea19 Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Tue, 5 May 2026 17:13:35 -0700 Subject: [PATCH] feat: added CACHE_COMPONENT_MS to manage component auth caching --- .env-cmdrc-template | 2 ++ npm-shrinkwrap.json | 30 ++++++++--------- package.json | 2 +- src/helpers/cache/index.js | 55 ++++++++++++++++++++++++------ src/middleware/auth.js | 22 ++++++++++-- tests/client-api-cached.test.js | 60 +++++++++++++++++++++++++++++++++ 6 files changed, 142 insertions(+), 29 deletions(-) diff --git a/.env-cmdrc-template b/.env-cmdrc-template index c6dcb94..88d9b40 100644 --- a/.env-cmdrc-template +++ b/.env-cmdrc-template @@ -16,6 +16,7 @@ "REGEX_MAX_BLACKLIST": 50, "MAX_REQUEST_PER_MINUTE": 0, "CACHE_SNAPSHOT_MS": 5000, + "CACHE_COMPONENT_MS": 60000, "SWITCHER_API_LOGGER": true, "SWITCHER_API_LOGGER_LEVEL": "debug", @@ -45,6 +46,7 @@ "REGEX_MAX_BLACKLIST": 50, "MAX_REQUEST_PER_MINUTE": 0, "CACHE_SNAPSHOT_MS": 5000, + "CACHE_COMPONENT_MS": 60000, "SWITCHER_API_LOGGER": false, "SWITCHER_API_LOGGER_LEVEL": "debug", diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 2c9f98e..de5dd15 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -37,7 +37,7 @@ "jest-sonar-reporter": "^2.0.0", "node-notifier": "^10.0.1", "nodemon": "^3.1.14", - "sinon": "^21.1.2", + "sinon": "^22.0.0", "supertest": "^7.2.2" } }, @@ -1345,9 +1345,9 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.2.tgz", - "integrity": "sha512-mrn35Jl2pCpns+mE3HaZa1yPN5EYCRgiMI+135COjr2hr8Cls9DXqIZ57vZe2cz7y2XVSq92tcs6kGQcT1J8Rw==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2756,9 +2756,9 @@ } }, "node_modules/diff": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", - "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -2802,9 +2802,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.349", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", - "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", + "version": "1.5.350", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.350.tgz", + "integrity": "sha512-/KWD4qK8nMqIoJh35Rpc37fiVyOe80mcUQKpfje0Dp9uot2ROuipsh+EriCdfInxjleD5v1S4OlIn41I0LXP0g==", "dev": true, "license": "ISC" }, @@ -6392,16 +6392,16 @@ } }, "node_modules/sinon": { - "version": "21.1.2", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.1.2.tgz", - "integrity": "sha512-FS6mN+/bx7e2ajpXkEmOcWB6xBzWiuNoAQT18/+a20SS4U7FSYl8Ms7N6VTUxN/1JAjkx7aXp+THMC8xdpp0gA==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-22.0.0.tgz", + "integrity": "sha512-sq/6DpdXOrLyfbKlXLg/Usc7xu8YXPeLkOFZRvA3bNUSA2lhbrZ06yuXbH1fkzBPCbz9O10+7hznzUsjaYNm0Q==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^15.3.2", + "@sinonjs/fake-timers": "^15.4.0", "@sinonjs/samsam": "^10.0.2", - "diff": "^8.0.4" + "diff": "^9.0.0" }, "funding": { "type": "opencollective", diff --git a/package.json b/package.json index d11bef4..4a769d4 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "jest-sonar-reporter": "^2.0.0", "node-notifier": "^10.0.1", "nodemon": "^3.1.14", - "sinon": "^21.1.2", + "sinon": "^22.0.0", "supertest": "^7.2.2" }, "repository": { diff --git a/src/helpers/cache/index.js b/src/helpers/cache/index.js index e4b0cc5..dfd916f 100644 --- a/src/helpers/cache/index.js +++ b/src/helpers/cache/index.js @@ -6,20 +6,23 @@ import { CacheWorkerManager } from './worker-manager.js'; import Logger from '../logger.js'; export default class Cache { - #instance; + static #instance; + #snapshotCache; + #componentCache; #workerManager; constructor() { - this.#instance = new Map(); + this.#snapshotCache = new Map(); + this.#componentCache = new Map(); this.#workerManager = null; } static getInstance() { - if (!Cache.instance) { - Cache.instance = new Cache(); + if (!Cache.#instance) { + Cache.#instance = new Cache(); } - return Cache.instance; + return Cache.#instance; } isEnabled() { @@ -90,22 +93,22 @@ export default class Cache { #handleCacheDeletions(deletions) { for (const domainId of deletions) { - this.#instance.delete(String(domainId)); + this.#snapshotCache.delete(String(domainId)); } } #handleCacheVersionRequest(domainId) { - const cached = this.#instance.get(String(domainId)); + const cached = this.#snapshotCache.get(String(domainId)); this.#workerManager.sendCacheVersionResponse(domainId, cached?.version); } #handleCachedDomainIdsRequest() { - const domainIds = Array.from(this.#instance.keys()); + const domainIds = Array.from(this.#snapshotCache.keys()); this.#workerManager.sendCachedDomainIdsResponse(domainIds); } #set(key, value) { - this.#instance.set(String(key), value); + this.#snapshotCache.set(String(key), value); } status() { @@ -113,10 +116,40 @@ export default class Cache { } get(key) { - return this.#instance.get(String(key)); + return this.#snapshotCache.get(String(key)); } getAll() { - return this.#instance; + return this.#snapshotCache; } + + // Component cache methods + + isComponentCacheEnabled() { + return process.env.CACHE_COMPONENT_MS !== undefined; + } + + refreshComponent(componentId, componentData) { + this.#componentCache.set(String(componentId), { + _id: componentId, + domain: componentData.domain, + name: componentData.name, + apihash: componentData.apihash, + cachedAt: Date.now() + }); + } + + getComponent(componentId, getComponentByIdfn) { + const entry = this.#componentCache.get(String(componentId)); + if (!entry) return undefined; + + if (Date.now() - entry.cachedAt > Number(process.env.CACHE_COMPONENT_MS)) { + getComponentByIdfn(componentId) + .then(component => this.refreshComponent(componentId, component)) + .catch(() => this.#componentCache.delete(String(componentId))); + } + + return entry; + } + } \ No newline at end of file diff --git a/src/middleware/auth.js b/src/middleware/auth.js index be83560..07ea991 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -5,6 +5,7 @@ import { getEnvironmentByDomainAndName } from '../services/environment.js'; import { responseExceptionSilent } from '../exceptions/index.js'; import Component from '../models/component.js'; import { getRateLimit } from '../external/switcher-api-facade.js'; +import Cache from '../helpers/cache/index.js'; export function resourcesAuth() { return basicAuth({ @@ -17,14 +18,27 @@ export function resourcesAuth() { export async function appAuth(req, res, next) { try { + const cache = Cache.getInstance(); const token = req.header('Authorization').replace('Bearer ', ''); const decoded = jwt.verify(token, process.env.JWT_SECRET); - const component = await getComponentById(decoded.component); - if (component?.apihash.substring(50, component.apihash.length - 1) !== decoded.vc) { + let component = cache.isComponentCacheEnabled() + ? cache.getComponent(decoded.component, getComponentById) + : undefined; + + const fromCache = component !== undefined; + if (!fromCache) { + component = await getComponentById(decoded.component); + } + + if (!isTokenValid(component, decoded)) { throw new Error('Invalid API token'); } + if (!fromCache && cache.isComponentCacheEnabled()) { + cache.refreshComponent(decoded.component, component); + } + req.token = token; req.domain = component.domain; req.component = component.name; @@ -62,4 +76,8 @@ export async function appGenerateCredentials(req, res, next) { } catch (err) { responseExceptionSilent(res, err, 401, 'Invalid token request.'); } +} + +function isTokenValid(component, decoded) { + return component?.apihash.substring(50, component.apihash.length - 1) === decoded.vc; } \ No newline at end of file diff --git a/tests/client-api-cached.test.js b/tests/client-api-cached.test.js index 877c3a4..abdca25 100644 --- a/tests/client-api-cached.test.js +++ b/tests/client-api-cached.test.js @@ -80,6 +80,7 @@ const createRequestAuth = async () => { beforeAll(async () => { await setupDatabase(); process.env.CACHE_SNAPSHOT_MS = 5000; + process.env.CACHE_COMPONENT_MS = 5000; }); afterAll(async () => { @@ -365,6 +366,7 @@ describe('Testing criteria [REST] ', () => { await Cache.getInstance().initializeCache(); const response = await createRequestAuth(); token = response.body.token; + Cache.getInstance().refreshComponent(component1._id.toString(), component1); }); test('CLIENT_CACHED_SUITE - Should return success on a entry-based CRITERIA response', async () => { @@ -540,6 +542,63 @@ describe('Testing criteria [REST] ', () => { expect(req.statusCode).toBe(404); }); + test('CLIENT_CACHED_SUITE - Should return stale response on TTL expiry then reject after background cache refresh', async () => { + // Seed cache with the current component (this becomes the "stale" entry after key rotation) + const componentBeforeRotation = await Component.findById(component1._id); + Cache.getInstance().refreshComponent(componentBeforeRotation._id.toString(), componentBeforeRotation); + + // Rotate API key externally (simulates switcher-api rotation — cache NOT updated) + const component = await Component.findById(component1._id); + const newApiKey = await component.generateApiKey(); + + // Force TTL expiry on the cached entry + process.env.CACHE_COMPONENT_MS = 1; + await new Promise(resolve => setTimeout(resolve, 5)); + + // First request after TTL: stale entry returned (old apihash), background refresh fires + await request(app) + .post(`/criteria?key=${keyConfig}&showReason=true&showStrategy=true`) + .set('Authorization', `Bearer ${token}`) + .send({ + entry: [ + { strategy: StrategiesType.VALUE, input: 'USER_1' }, + { strategy: StrategiesType.NETWORK, input: '10.0.0.3' } + ]}) + .expect(200); + + // Wait for background refresh to complete + await new Promise(resolve => setTimeout(resolve, 200)); + + // Restore TTL + process.env.CACHE_COMPONENT_MS = 5000; + + // Second request: cache now has new apihash, old token rejected + const rejectedResponse = await request(app) + .post(`/criteria?key=${keyConfig}&showReason=true&showStrategy=true`) + .set('Authorization', `Bearer ${token}`) + .send({ + entry: [ + { strategy: StrategiesType.VALUE, input: 'USER_1' }, + { strategy: StrategiesType.NETWORK, input: '10.0.0.3' } + ]}) + .expect(401); + + expect(rejectedResponse.body.error).toEqual('Invalid API token.'); + + // Renew token for subsequent tests + const renewedTokenResponse = await request(app) + .post('/criteria/auth') + .set('switcher-api-key', `${newApiKey}`) + .send({ + domain: domainDocument.name, + component: component1.name, + environment: EnvType.DEFAULT + }).expect(200); + + token = renewedTokenResponse.body.token; + Cache.getInstance().refreshComponent(component._id.toString(), component); + }); + test('CLIENT_CACHED_SUITE - Should NOT return due to a API Key change, then it should return after renewing the token', async () => { const firstResponse = await request(app) .post(`/criteria?key=${keyConfig}&showReason=true&showStrategy=true`) @@ -563,6 +622,7 @@ describe('Testing criteria [REST] ', () => { // Change API Key const component = await Component.findById(component1._id); const newApiKey = await component.generateApiKey(); + Cache.getInstance().refreshComponent(component._id.toString(), component); const secondResponse = await request(app) .post(`/criteria?key=${keyConfig}&showReason=true&showStrategy=true`)