propro-utils 1.7.53 → 1.7.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  require('dotenv').config();
2
2
  const axios = require('axios');
3
+ const crypto = require('crypto');
3
4
  const { getOrSetCache } = require('../utils/redis');
4
5
  const { checkIfUserExists } = require('./account_info');
5
6
  const ServiceManager = require('../utils/serviceManager');
@@ -34,6 +35,40 @@ const ServiceManager = require('../utils/serviceManager');
34
35
  * res.json({ message: 'You have access to protected data' });
35
36
  * });
36
37
  */
38
+ const USER_CACHE_TTL_SECONDS = 60;
39
+ const IN_PROCESS_AUTH_CACHE_TTL_MS = 5000;
40
+ const MAX_IN_PROCESS_CACHE_ENTRIES = 1000;
41
+ const validationMemoryCache = new Map();
42
+ const userMemoryCache = new Map();
43
+
44
+ const hashValue = value => crypto.createHash('sha256').update(value).digest('hex');
45
+ const permissionsCachePart = permissions => [...permissions].sort().join(',');
46
+
47
+ const getFromMemoryCache = (cache, key) => {
48
+ const entry = cache.get(key);
49
+ if (!entry) return null;
50
+ if (entry.expiresAt <= Date.now()) {
51
+ cache.delete(key);
52
+ return null;
53
+ }
54
+ return entry.value;
55
+ };
56
+
57
+ const setInMemoryCache = (cache, key, value) => {
58
+ if (cache.size >= MAX_IN_PROCESS_CACHE_ENTRIES) {
59
+ cache.clear();
60
+ }
61
+ cache.set(key, {
62
+ value,
63
+ expiresAt: Date.now() + IN_PROCESS_AUTH_CACHE_TTL_MS,
64
+ });
65
+ };
66
+
67
+ const clearMemoryCaches = () => {
68
+ validationMemoryCache.clear();
69
+ userMemoryCache.clear();
70
+ };
71
+
37
72
  const authValidation = (requiredPermissions = []) => {
38
73
  return async (req, res, next) => {
39
74
  try {
@@ -56,13 +91,19 @@ const authValidation = (requiredPermissions = []) => {
56
91
  return response.data;
57
92
  };
58
93
  const redisClient = await ServiceManager.getService('RedisClient');
59
- const cacheKey = `account:permissions:${accessToken}`;
60
- const { accountId, validPermissions } = await getOrSetCache(
61
- redisClient,
62
- cacheKey,
63
- fetchPermission,
64
- 1800
65
- );
94
+ const tokenHash = hashValue(accessToken);
95
+ const requiredPermissionsKey = permissionsCachePart(requiredPermissions);
96
+ const validationCacheKey = `account:permissions:${tokenHash}:${requiredPermissionsKey}`;
97
+ const validationResult =
98
+ getFromMemoryCache(validationMemoryCache, validationCacheKey) ||
99
+ await getOrSetCache(
100
+ redisClient,
101
+ validationCacheKey,
102
+ fetchPermission,
103
+ 1800
104
+ );
105
+ setInMemoryCache(validationMemoryCache, validationCacheKey, validationResult);
106
+ const { accountId, validPermissions } = validationResult;
66
107
 
67
108
  if (!validPermissions) {
68
109
  return res.status(403).json({ error: 'Invalid permissions' });
@@ -72,8 +113,18 @@ const authValidation = (requiredPermissions = []) => {
72
113
 
73
114
  let user = null;
74
115
  try {
75
- user = await checkIfUserExists(accountId);
76
- if (!user) throw new Error('User not found');
116
+ const userCacheKey = `account:user:${accountId}`;
117
+ user = getFromMemoryCache(userMemoryCache, userCacheKey);
118
+ if (!user) {
119
+ user = await getOrSetCache(
120
+ redisClient,
121
+ userCacheKey,
122
+ () => checkIfUserExists(accountId),
123
+ USER_CACHE_TTL_SECONDS
124
+ );
125
+ setInMemoryCache(userMemoryCache, userCacheKey, user);
126
+ }
127
+ if (!user?.id) throw new Error('User not found');
77
128
  } catch (error) {
78
129
  return res.status(403).json({error: error?.message || 'User not found'});
79
130
  }
@@ -82,11 +133,12 @@ const authValidation = (requiredPermissions = []) => {
82
133
  next();
83
134
  } catch (error) {
84
135
  if (error.response && error.response.status) {
85
- next(new Error(error.response.data.message));
136
+ return next(new Error(error.response.data.message));
86
137
  }
87
- next(new Error('Error validating token'));
138
+ return next(new Error('Error validating token'));
88
139
  }
89
140
  };
90
141
  };
91
142
 
92
143
  module.exports = authValidation;
144
+ module.exports.clearMemoryCaches = clearMemoryCaches;
@@ -1,84 +1,124 @@
1
+ jest.mock('axios');
2
+ jest.mock('../utils/redis', () => ({
3
+ getOrSetCache: jest.fn(),
4
+ }));
5
+ jest.mock('../utils/serviceManager', () => ({
6
+ getService: jest.fn(),
7
+ }));
8
+ jest.mock('./account_info', () => ({
9
+ checkIfUserExists: jest.fn(),
10
+ }));
11
+
1
12
  const authValidation = require('./access_token');
2
13
  const axios = require('axios');
3
- const { createMockExpressContext } = require('../utils/testUtils');
14
+ const { getOrSetCache } = require('../utils/redis');
15
+ const ServiceManager = require('../utils/serviceManager');
16
+ const { checkIfUserExists } = require('./account_info');
4
17
 
5
- jest.mock('axios');
18
+ const createReq = () => ({
19
+ cookies: {},
20
+ headers: {},
21
+ });
22
+
23
+ const createRes = () => ({
24
+ status: jest.fn().mockReturnThis(),
25
+ json: jest.fn(),
26
+ });
6
27
 
7
28
  describe('authValidation middleware', () => {
8
- let req, res, next;
29
+ let req;
30
+ let res;
31
+ let next;
32
+ let redisClient;
9
33
 
10
34
  beforeEach(() => {
11
- req = createMockExpressContext('/api/test');
12
- res = {
13
- status: jest.fn().mockReturnThis(),
14
- json: jest.fn(),
15
- };
35
+ authValidation.clearMemoryCaches();
36
+ req = createReq();
37
+ res = createRes();
16
38
  next = jest.fn();
39
+ redisClient = { get: jest.fn(), setEx: jest.fn() };
40
+ ServiceManager.getService.mockReturnValue(redisClient);
41
+ getOrSetCache.mockReset();
42
+ checkIfUserExists.mockReset();
43
+ axios.post.mockReset();
17
44
  });
18
45
 
19
- afterEach(() => {
20
- jest.clearAllMocks();
21
- });
22
-
23
- it('should return 403 if access token is missing', async () => {
24
- // const middleware = authValidation({}, {});
46
+ it('returns 403 if access token is missing', async () => {
47
+ const middleware = authValidation(['user']);
25
48
 
26
- // await middleware(req, res, next);
49
+ await middleware(req, res, next);
27
50
 
28
- // expect(res.status).toHaveBeenCalledWith(403);
29
- // expect(res.json).toHaveBeenCalledWith({
30
- // error: 'Access token is required',
31
- // });
32
- // expect(next).not.toHaveBeenCalled();
33
- expect(1).toBe(1);
51
+ expect(res.status).toHaveBeenCalledWith(403);
52
+ expect(res.json).toHaveBeenCalledWith({ error: 'Access token is required' });
53
+ expect(ServiceManager.getService).not.toHaveBeenCalled();
54
+ expect(getOrSetCache).not.toHaveBeenCalled();
55
+ expect(next).not.toHaveBeenCalled();
34
56
  });
35
57
 
36
- // it('should return 403 if permissions are invalid', async () => {
37
- // req.cookies['x-access-token'] = 'testAccessToken';
38
- // axios.post.mockRejectedValue({
39
- // response: { status: 403, data: { message: 'Invalid permissions' } },
40
- // });
41
-
42
- // const middleware = authValidation({}, {}, ['permission1', 'permission2']);
43
-
44
- // await middleware(req, res, next);
45
-
46
- // expect(res.status).toHaveBeenCalledWith(403);
47
- // expect(res.json).toHaveBeenCalledWith({ error: 'Invalid permissions' });
48
- // expect(next).not.toHaveBeenCalled();
49
- // });
50
-
51
- // it('should set req.account and req.user if token is valid and user exists', async () => {
52
- // req.cookies['x-access-token'] = 'testAccessToken';
53
- // axios.post.mockResolvedValue({ data: { accountId: 'testAccountId' } });
54
-
55
- // const userSchemaMock = {
56
- // checkIfUserExists: jest.fn().mockResolvedValue({ id: 'testUserId' }),
57
- // };
58
- // const middleware = authValidation({}, userSchemaMock, [
59
- // 'permission1',
60
- // 'permission2',
61
- // ]);
62
-
63
- // await middleware(req, res, next);
64
-
65
- // expect(req.account).toBe('testAccountId');
66
- // expect(req.user).toBe('testUserId');
67
- // expect(res.status).not.toHaveBeenCalled();
68
- // expect(res.json).not.toHaveBeenCalled();
69
- // expect(next).toHaveBeenCalled();
70
- // });
58
+ it('uses cached token permissions and cached user context on cache hits', async () => {
59
+ req.headers.authorization = 'Bearer token-1';
60
+ getOrSetCache
61
+ .mockResolvedValueOnce({ accountId: 'account-1', validPermissions: true })
62
+ .mockResolvedValueOnce({ id: 'user-1' });
63
+
64
+ const middleware = authValidation(['user']);
65
+ await middleware(req, res, next);
66
+
67
+ expect(ServiceManager.getService).toHaveBeenCalledWith('RedisClient');
68
+ expect(getOrSetCache).toHaveBeenNthCalledWith(
69
+ 1,
70
+ redisClient,
71
+ 'account:permissions:3f08aace122ee2368432c1ca23a049bc640bafbf00fdf33a52429f38ba12dbf9:user',
72
+ expect.any(Function),
73
+ 1800
74
+ );
75
+ expect(getOrSetCache).toHaveBeenNthCalledWith(
76
+ 2,
77
+ redisClient,
78
+ 'account:user:account-1',
79
+ expect.any(Function),
80
+ 60
81
+ );
82
+ expect(axios.post).not.toHaveBeenCalled();
83
+ expect(checkIfUserExists).not.toHaveBeenCalled();
84
+ expect(req.account).toBe('account-1');
85
+ expect(req.user).toBe('user-1');
86
+ expect(next).toHaveBeenCalledWith();
87
+ });
71
88
 
72
- // it('should call next with error if token validation fails', async () => {
73
- // req.cookies['x-access-token'] = 'testAccessToken';
74
- // axios.post.mockRejectedValue(new Error('Token validation error'));
89
+ it('populates the user cache by calling checkIfUserExists on user cache miss', async () => {
90
+ req.cookies['x-access-token'] = 'cookie-token';
91
+ checkIfUserExists.mockResolvedValue({ id: 'created-user' });
92
+ getOrSetCache.mockImplementation(async (_client, key, service) => {
93
+ if (key === 'account:permissions:5f8f34c90be6a68dc1dd28ce60d8716554635c21c29c84b5f4a78ab75d38ab4b:user') {
94
+ return { accountId: 'account-2', validPermissions: true };
95
+ }
96
+ if (key === 'account:user:account-2') {
97
+ return service();
98
+ }
99
+ throw new Error(`unexpected cache key ${key}`);
100
+ });
101
+
102
+ const middleware = authValidation(['user']);
103
+ await middleware(req, res, next);
104
+
105
+ expect(checkIfUserExists).toHaveBeenCalledWith('account-2');
106
+ expect(req.account).toBe('account-2');
107
+ expect(req.user).toBe('created-user');
108
+ expect(next).toHaveBeenCalledWith();
109
+ });
75
110
 
76
- // const middleware = authValidation({}, {});
111
+ it('rejects when the cached or loaded user is invalid', async () => {
112
+ req.headers.authorization = 'Bearer token-2';
113
+ getOrSetCache
114
+ .mockResolvedValueOnce({ accountId: 'account-3', validPermissions: true })
115
+ .mockResolvedValueOnce(null);
77
116
 
78
- // await middleware(req, res, next);
117
+ const middleware = authValidation(['user']);
118
+ await middleware(req, res, next);
79
119
 
80
- // expect(next).toHaveBeenCalledWith(new Error('Error validating token'));
81
- // expect(res.status).not.toHaveBeenCalled();
82
- // expect(res.json).not.toHaveBeenCalled();
83
- // });
120
+ expect(res.status).toHaveBeenCalledWith(403);
121
+ expect(res.json).toHaveBeenCalledWith({ error: 'User not found' });
122
+ expect(next).not.toHaveBeenCalled();
123
+ });
84
124
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "propro-utils",
3
- "version": "1.7.53",
3
+ "version": "1.7.54",
4
4
  "description": "Auth middleware for propro-auth",
5
5
  "main": "src/index.js",
6
6
  "private": false,