Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env-cmdrc-template
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
30 changes: 15 additions & 15 deletions npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
55 changes: 44 additions & 11 deletions src/helpers/cache/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -90,33 +93,63 @@ 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() {
return this.#workerManager?.getStatus();
}

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;
}

}
22 changes: 20 additions & 2 deletions src/middleware/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
60 changes: 60 additions & 0 deletions tests/client-api-cached.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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`)
Expand All @@ -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`)
Expand Down