Skip to content

Commit ee48924

Browse files
authored
Merge pull request #342 from Kirtan-pc/fix/allowed-domains-cache-invalidation
fix(cache): invalidate both raw and hashed publishable keys and encrypt jwtSecret in Redis
1 parent ab9bdff commit ee48924

4 files changed

Lines changed: 331 additions & 96 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
'use strict';
2+
3+
process.env.ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
4+
5+
const store = new Map();
6+
const mockRedis = {
7+
status: 'ready',
8+
set: jest.fn((key, val) => {
9+
store.set(key, val);
10+
return Promise.resolve('OK');
11+
}),
12+
get: jest.fn((key) => {
13+
return Promise.resolve(store.get(key) || null);
14+
}),
15+
del: jest.fn((key) => {
16+
const existed = store.has(key);
17+
store.delete(key);
18+
return Promise.resolve(existed ? 1 : 0);
19+
}),
20+
};
21+
22+
// Mock the redis configuration module used by packages/common
23+
jest.mock('../../../../packages/common/src/config/redis', () => mockRedis);
24+
25+
const {
26+
setProjectByApiKeyCache,
27+
getProjectByApiKeyCache,
28+
deleteProjectByApiKeyCache,
29+
setProjectById,
30+
getProjectById,
31+
} = require('../../../../packages/common/src/redis/redisCaching');
32+
33+
const { hashApiKey } = require('../../../../packages/common/src/utils/api');
34+
35+
describe('redisCaching functions', () => {
36+
beforeEach(() => {
37+
store.clear();
38+
jest.clearAllMocks();
39+
});
40+
41+
test('setProjectByApiKeyCache encrypts jwtSecret and getProjectByApiKeyCache decrypts it', async () => {
42+
const project = {
43+
_id: 'project_1',
44+
name: 'Test Project',
45+
jwtSecret: 'super-secret-jwt-key',
46+
allowedDomains: ['*'],
47+
};
48+
49+
await setProjectByApiKeyCache('pk_live_123', project);
50+
51+
// Verify the mockRedis.set was called
52+
expect(mockRedis.set).toHaveBeenCalled();
53+
54+
// Inspect stored value
55+
const storedStr = store.get('project:apikey:pk_live_123');
56+
expect(storedStr).toBeDefined();
57+
58+
const storedData = JSON.parse(storedStr);
59+
// jwtSecret should NOT be the raw plaintext string
60+
expect(storedData.jwtSecret).not.toBe('super-secret-jwt-key');
61+
expect(storedData.jwtSecret).toHaveProperty('iv');
62+
expect(storedData.jwtSecret).toHaveProperty('encrypted');
63+
expect(storedData.jwtSecret).toHaveProperty('tag');
64+
65+
// Retrieve and verify it is decrypted
66+
const retrieved = await getProjectByApiKeyCache('pk_live_123');
67+
expect(retrieved.jwtSecret).toBe('super-secret-jwt-key');
68+
expect(retrieved.name).toBe('Test Project');
69+
});
70+
71+
test('getProjectByApiKeyCache falls back gracefully to plaintext jwtSecret (migration path)', async () => {
72+
// Manually store raw plaintext jwtSecret in Redis store
73+
const oldData = {
74+
_id: 'project_2',
75+
name: 'Old Cache Project',
76+
jwtSecret: 'plaintext-old-secret',
77+
};
78+
store.set('project:apikey:pk_live_456', JSON.stringify(oldData));
79+
80+
const retrieved = await getProjectByApiKeyCache('pk_live_456');
81+
expect(retrieved.jwtSecret).toBe('plaintext-old-secret');
82+
});
83+
84+
test('setProjectById encrypts jwtSecret and getProjectById decrypts it', async () => {
85+
const project = {
86+
_id: 'project_id_1',
87+
jwtSecret: 'id-secret',
88+
};
89+
90+
await setProjectById('project_id_1', project);
91+
92+
const storedStr = store.get('project:id:project_id_1');
93+
const storedData = JSON.parse(storedStr);
94+
expect(storedData.jwtSecret).not.toBe('id-secret');
95+
96+
const retrieved = await getProjectById('project_id_1');
97+
expect(retrieved.jwtSecret).toBe('id-secret');
98+
});
99+
100+
test('deleteProjectByApiKeyCache invalidates both raw and hashed keys', async () => {
101+
const rawKey = 'pk_live_some_test_key';
102+
const hashedKey = hashApiKey(rawKey);
103+
104+
// Put mock entries for both keys
105+
store.set(`project:apikey:${rawKey}`, 'raw_val');
106+
store.set(`project:apikey:${hashedKey}`, 'hashed_val');
107+
108+
await deleteProjectByApiKeyCache(rawKey);
109+
110+
// Both keys should be deleted
111+
expect(store.has(`project:apikey:${rawKey}`)).toBe(false);
112+
expect(store.has(`project:apikey:${hashedKey}`)).toBe(false);
113+
});
114+
115+
test('deleteProjectByApiKeyCache invalidates only the hashed key if a hashed key is passed', async () => {
116+
const hashedKey = hashApiKey('pk_live_some_test_key');
117+
store.set(`project:apikey:${hashedKey}`, 'hashed_val');
118+
119+
await deleteProjectByApiKeyCache(hashedKey);
120+
121+
expect(store.has(`project:apikey:${hashedKey}`)).toBe(false);
122+
});
123+
});

apps/public-api/src/__tests__/verifyApiKey.test.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ jest.mock('@urbackend/common', () => {
2929
};
3030
});
3131

32-
const { hashApiKey, getProjectByApiKeyCache, Project } = require('@urbackend/common');
32+
const { hashApiKey, getProjectByApiKeyCache, setProjectByApiKeyCache, Project } = require('@urbackend/common');
3333
const verifyApiKey = require('../middlewares/verifyApiKey');
3434

3535
// ---------------------------------------------------------------------------
@@ -175,6 +175,17 @@ describe('verifyApiKey middleware', () => {
175175
expect(res.status).not.toHaveBeenCalled();
176176
});
177177

178+
test('does not cache project when owner is not verified', async () => {
179+
mockLean.mockResolvedValueOnce(makeProject({ owner: { isVerified: false } }));
180+
const req = makeReq({ query: { key: 'pk_live_unverifiedowner' } });
181+
const res = makeRes();
182+
183+
await verifyApiKey(req, res, next);
184+
185+
expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 401, error: 'Owner not verified' }));
186+
expect(setProjectByApiKeyCache).not.toHaveBeenCalled();
187+
});
188+
178189
test('uses cache when available and does not query DB', async () => {
179190
getProjectByApiKeyCache.mockResolvedValueOnce(makeProject());
180191
const req = makeReq({ query: { key: 'pk_live_cached' } });

0 commit comments

Comments
 (0)