soajs.controller 4.3.19 → 4.3.21

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.
@@ -17,10 +17,9 @@ jobs:
17
17
 
18
18
  services:
19
19
  mongodb:
20
- # TODO: back to mongo:8 once it starts on the GitHub runners again. Since 2026-10-19 mongod 8
21
- # exits on start with "Linux kernel versions 6.19 and newer has a known incompatibility
22
- # with this version of MongoDB" (https://jira.mongodb.org/browse/SERVER-121912).
23
- image: mongo:7.0
20
+ image: mongo:8
21
+ env:
22
+ GLIBC_TUNABLES: glibc.pthread.rseq=1 # disable tcmalloc per-CPU cache; mongo:8 refuses to start on kernel 6.19+ otherwise (SERVER-121912)
24
23
  ports:
25
24
  - 27017:27017
26
25
 
@@ -17,10 +17,9 @@ jobs:
17
17
 
18
18
  services:
19
19
  mongodb:
20
- # TODO: back to mongo:8 once it starts on the GitHub runners again. Since 2026-10-19 mongod 8
21
- # exits on start with "Linux kernel versions 6.19 and newer has a known incompatibility
22
- # with this version of MongoDB" (https://jira.mongodb.org/browse/SERVER-121912).
23
- image: mongo:7.0
20
+ image: mongo:8
21
+ env:
22
+ GLIBC_TUNABLES: glibc.pthread.rseq=1 # disable tcmalloc per-CPU cache; mongo:8 refuses to start on kernel 6.19+ otherwise (SERVER-121912)
24
23
  ports:
25
24
  - 27017:27017
26
25
 
@@ -18,6 +18,7 @@ The maintenance server runs on a separate port from the main gateway to allow in
18
18
  | `/loadProvision` | GET | Reload provision/tenant data |
19
19
  | `/reloadRegistry` | GET | Reload environment registry |
20
20
  | `/awarenessStat` | GET | Get service awareness statistics |
21
+ | `/cache/invalidate` | POST | Remove cached GET responses a service knows are out of date |
21
22
 
22
23
  ---
23
24
 
@@ -226,6 +227,36 @@ curl http://localhost:5000/awarenessStat
226
227
 
227
228
  ---
228
229
 
230
+ ## 5. Cache Invalidation (`/cache/invalidate`)
231
+
232
+ **Endpoint:** `POST /cache/invalidate`
233
+
234
+ **Purpose:** Lets a service remove cached GET responses as soon as it knows they changed, instead of
235
+ waiting for their TTL. Needs the mongo cache model.
236
+
237
+ ### Request
238
+
239
+ ```bash
240
+ curl -X POST http://localhost:5000/cache/invalidate \
241
+ -H "Content-Type: application/json" \
242
+ -d '{"tenant": "5c0e74ba9acc3c5a84a51259", "service": "spaces", "api": "GET /spaces", "user": "5c8d0c505653de3985aa2001"}'
243
+ ```
244
+
245
+ ### Response
246
+
247
+ ```json
248
+ {
249
+ "result": true,
250
+ "ts": 1792430000000,
251
+ "service": { "service": "soajs.controller", "version": "4.3.20", "type": "rest", "route": "/cache/invalidate" },
252
+ "data": { "removed": 3 }
253
+ }
254
+ ```
255
+
256
+ What each field matches, and the error cases, are in [Cache, Invalidation](middleware/cache.md#invalidation).
257
+
258
+ ---
259
+
229
260
  ## Security Considerations
230
261
 
231
262
  ### Network Access
@@ -157,7 +157,7 @@ APIs are matched using `GET /path` format with support for path parameters:
157
157
  - **tenant_id**: Isolates cache per tenant
158
158
  - **serviceName**: The target service name
159
159
  - **path**: The API path
160
- - **queryHash**: MD5 hash of sorted query parameters
160
+ - **queryHash**: MD5 hash of sorted query parameters, without `access_token`
161
161
  - **user_id**: The logged-in user id (only for `tenant_user` scope), isolating cache per user within the tenant
162
162
 
163
163
  ### Query Parameter Handling
@@ -168,8 +168,14 @@ Query parameters are included in the cache key via MD5 hash:
168
168
  GET /users?page=1&limit=10 → key includes hash of {"limit":"10","page":"1"}
169
169
  GET /users?limit=10&page=1 → same hash (sorted)
170
170
  GET /users → different key (no query hash)
171
+ GET /users?access_token=abc → same key as GET /users
171
172
  ```
172
173
 
174
+ `access_token` is left out: it identifies the caller, not what is asked for. With it in the key, every
175
+ token got its own entry, so a `tenant` scoped API was never shared between users, and a user's entries
176
+ stopped matching after each login or token refresh. The user of a `tenant_user` entry is already in the
177
+ key.
178
+
173
179
  ## Storage Backends
174
180
 
175
181
  ### Memory (Default)
@@ -229,6 +235,73 @@ Content-Type: application/json
229
235
  - Error responses (4xx, 5xx)
230
236
  - APIs not configured for caching
231
237
 
238
+ ## Invalidation
239
+
240
+ A cached response normally lives until its TTL. A service that knows something changed can remove the
241
+ affected entries right away by calling the gateway's maintenance port:
242
+
243
+ ```
244
+ POST /cache/invalidate
245
+ Content-Type: application/json
246
+
247
+ {
248
+ "tenant": "5c0e74ba9acc3c5a84a51259", // required, the tenant the GET was cached under
249
+ "service": "spaces", // required
250
+ "api": "GET /spaces", // optional, as written in the cache configuration
251
+ "user": "5c8d0c505653de3985aa2001" // optional, the user id of a tenant_user entry
252
+ }
253
+ ```
254
+
255
+ ```json
256
+ { "result": true, "data": { "removed": 3 } }
257
+ ```
258
+
259
+ | Given | Removed |
260
+ |-------|---------|
261
+ | `tenant`, `service`, `api`, `user` | That user's entries of that api |
262
+ | `tenant`, `service`, `api` | That api's entries for every user, and its tenant scoped entry |
263
+ | `tenant`, `service`, `user` | That user's entries of every api of the service |
264
+ | `tenant`, `service` | Every entry of the service in the tenant |
265
+
266
+ Every cached variant of a matching entry is removed: every query string, and every value of a path
267
+ parameter. `"api": "GET /space/:id"` removes `/space/1`, `/space/2` and so on. A `user` only matches
268
+ `tenant_user` entries, a `tenant` scoped entry has no user.
269
+
270
+ **Only the mongo model is supported.** It is the one shared by every gateway, so one call clears the
271
+ entry everywhere. With the memory model the route answers `400`.
272
+
273
+ **Example.** A service caches `GET /spaces`, the spaces a user is invited to, per user. When user A
274
+ invites user B, the invite API removes B's list before it answers, so B's next call loads the new one:
275
+
276
+ ```javascript
277
+ // at the end of the invite API, before replying
278
+ await post("http://" + gatewayHost + ":" + maintenancePort + "/cache/invalidate", {
279
+ "tenant": req.soajs.tenant.id,
280
+ "service": "spaces",
281
+ "api": "GET /spaces",
282
+ "user": invitedUserId
283
+ });
284
+ ```
285
+
286
+ Wait for it with a short timeout. If the gateway cannot be reached, the change still stands and the
287
+ entry expires on its TTL.
288
+
289
+ **Requests already in flight.** A GET that started before the invalidation may have read the data
290
+ before the change. Its response is still sent to the client but not stored, so it cannot put the old
291
+ list back after the removal. Each invalidation is remembered for 15 minutes in the `cache_invalidation`
292
+ collection for this check. It compares the time the gateway handling the GET received it with the time
293
+ the invalidation ran, keep the gateways' clocks in sync.
294
+
295
+ Entries cached before this version carry no service, api or user, an invalidation does not match them,
296
+ they expire on their TTL.
297
+
298
+ | Status | Reason |
299
+ |--------|--------|
300
+ | `200` | Removed, `data.removed` is the number of entries |
301
+ | `400` | Missing `tenant` or `service`, an `api` not written as `GET /path`, a `user` that is not a string, caching not configured, or not the mongo model |
302
+ | `405` | Not a `POST` |
303
+ | `500` | The cache store failed or is not ready |
304
+
232
305
  ## Usage Notes
233
306
 
234
307
  - Only GET requests are cached
package/mw/cache/index.js CHANGED
@@ -16,17 +16,28 @@ const { logOnce } = require('../../lib/logOnce.js');
16
16
 
17
17
  let model = { memory, mongo };
18
18
 
19
+ //NOTE: the access token is how the caller is identified, not what is asked for. Kept in, every token
20
+ // gets its own entry and a tenant scoped api is never shared, the user is already in the key of a
21
+ // tenant_user entry.
22
+ const QUERY_PARAMS_NOT_IN_KEY = ['access_token'];
23
+
19
24
  function hashQuery(query) {
20
- if (!query || Object.keys(query).length === 0) {
25
+ if (!query) {
26
+ return '';
27
+ }
28
+ const params = Object.keys(query).filter((key) => !QUERY_PARAMS_NOT_IN_KEY.includes(key));
29
+ if (params.length === 0) {
21
30
  return '';
22
31
  }
23
- const sortedQuery = Object.keys(query).sort().reduce((obj, key) => {
32
+ const sortedQuery = params.sort().reduce((obj, key) => {
24
33
  obj[key] = query[key];
25
34
  return obj;
26
35
  }, {});
27
36
  return crypto.createHash('md5').update(JSON.stringify(sortedQuery)).digest('hex');
28
37
  }
29
38
 
39
+ //NOTE: returns the entry name as written in the configuration with its config, an invalidation names
40
+ // the api the same way
30
41
  function matchesApi(method, path, apis) {
31
42
  if (!apis || typeof apis !== 'object') {
32
43
  return null;
@@ -34,7 +45,7 @@ function matchesApi(method, path, apis) {
34
45
  const requestSignature = `${method} ${path}`;
35
46
 
36
47
  if (apis[requestSignature]) {
37
- return apis[requestSignature];
48
+ return { 'name': requestSignature, 'config': apis[requestSignature] };
38
49
  }
39
50
 
40
51
  for (const api in apis) {
@@ -59,7 +70,7 @@ function matchesApi(method, path, apis) {
59
70
  }
60
71
  }
61
72
  if (matches) {
62
- return apis[api];
73
+ return { 'name': api, 'config': apis[api] };
63
74
  }
64
75
  }
65
76
  }
@@ -96,7 +107,8 @@ module.exports = function (configuration) {
96
107
  return next();
97
108
  }
98
109
 
99
- const apiConfig = matchesApi(method, apiPath, serviceConfig.apis);
110
+ const matchedApi = matchesApi(method, apiPath, serviceConfig.apis);
111
+ const apiConfig = matchedApi ? matchedApi.config : null;
100
112
  if (!apiConfig || !apiConfig.enabled) {
101
113
  return next();
102
114
  }
@@ -141,6 +153,7 @@ module.exports = function (configuration) {
141
153
  'l2': `${serviceName}:GET:${apiPath}:${queryHash}${userPart}`
142
154
  };
143
155
 
156
+ const startedAt = Date.now();
144
157
  const processCache = async () => {
145
158
  try {
146
159
  const cached = await model[modelType].get(key);
@@ -157,6 +170,12 @@ module.exports = function (configuration) {
157
170
  req.soajs.cacheContext = {
158
171
  key: key,
159
172
  ttl: ttl,
173
+ meta: {
174
+ service: serviceName,
175
+ api: matchedApi.name,
176
+ user: (scope === 'tenant_user') ? String(userId) : null,
177
+ startedAt: startedAt
178
+ },
160
179
  modelType: modelType,
161
180
  model: model[modelType],
162
181
  log: configuration.log
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @license
5
+ * Copyright SOAJS All Rights Reserved.
6
+ *
7
+ * Use of this source code is governed by an Apache license that can be
8
+ * found in the LICENSE file at the root of this repository
9
+ */
10
+
11
+ const get = (p, o) => p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o);
12
+ const mongo = require('./model/mongo.js');
13
+
14
+ const API_REGEX = /^GET \/\S*$/;
15
+
16
+ function isNonEmptyString(value) {
17
+ return typeof value === 'string' && value.length > 0;
18
+ }
19
+
20
+ /**
21
+ * Remove cached GET responses a service knows are out of date, called on the maintenance port.
22
+ *
23
+ * NOTE: only the mongo model is supported, it is the one shared by every gateway, the memory model
24
+ * is per gateway and meant for testing
25
+ *
26
+ * @param registry the gateway registry
27
+ * @param input {tenant, service, api, user} tenant and service are required
28
+ * @returns {Promise<{status: number, removed: number}|{status: number, error: string}>}
29
+ */
30
+ async function invalidate(registry, input) {
31
+ if (!input || typeof input !== 'object') {
32
+ return { 'status': 400, 'error': 'A JSON body with tenant and service is required.' };
33
+ }
34
+ if (!isNonEmptyString(input.tenant) || !isNonEmptyString(input.service)) {
35
+ return { 'status': 400, 'error': 'tenant and service are required.' };
36
+ }
37
+ if (input.api !== undefined && (!isNonEmptyString(input.api) || !API_REGEX.test(input.api))) {
38
+ return { 'status': 400, 'error': 'api must be written as in the cache configuration, for example "GET /spaces".' };
39
+ }
40
+ if (input.user !== undefined && !isNonEmptyString(input.user)) {
41
+ return { 'status': 400, 'error': 'user must be a user id.' };
42
+ }
43
+
44
+ const cacheConfig = get(["custom", "gateway", "value", "cache"], registry);
45
+ if (!cacheConfig) {
46
+ return { 'status': 400, 'error': 'Caching is not configured.' };
47
+ }
48
+ if (cacheConfig.model !== 'mongo') {
49
+ return { 'status': 400, 'error': 'Cache invalidation needs the mongo model, the cache model is [' + (cacheConfig.model || 'memory') + '].' };
50
+ }
51
+ if (!mongo.mongo) {
52
+ return { 'status': 500, 'error': 'The cache store is not ready.' };
53
+ }
54
+
55
+ try {
56
+ const removed = await mongo.invalidateMatching({
57
+ 'tenant': input.tenant,
58
+ 'service': input.service,
59
+ 'api': input.api,
60
+ 'user': input.user
61
+ });
62
+ return { 'status': 200, 'removed': removed };
63
+ } catch (e) {
64
+ if (mongo.log) {
65
+ mongo.log.error('Cache invalidate:', e.message);
66
+ }
67
+ return { 'status': 500, 'error': 'Cache invalidation failed.' };
68
+ }
69
+ }
70
+
71
+ module.exports = invalidate;
@@ -12,6 +12,11 @@ const core = require('soajs.core.modules');
12
12
  const Mongo = core.mongo;
13
13
 
14
14
  const cache_store = 'cache_store';
15
+ const cache_invalidation = 'cache_invalidation';
16
+
17
+ //NOTE: an invalidation is remembered long enough to outlive any request that started before it,
18
+ // so that request cannot store what it read before the change
19
+ const INVALIDATION_RETENTION = 15 * 60 * 1000;
15
20
 
16
21
  let model = {
17
22
  'mongo': null,
@@ -35,6 +40,27 @@ let model = {
35
40
  log.debug("Cache TTL index created: " + index);
36
41
  }
37
42
  });
43
+ model.mongo.createIndex(cache_store, { 'l1': 1, 'service': 1, 'api': 1, 'user': 1 }, {}, (err, index) => {
44
+ if (err) {
45
+ log.error("Cache invalidation lookup index creation failed: " + err.message);
46
+ } else {
47
+ log.debug("Cache invalidation lookup index created: " + index);
48
+ }
49
+ });
50
+ model.mongo.createIndex(cache_invalidation, { 'l1': 1, 'service': 1, 'api': 1, 'user': 1 }, { unique: true }, (err, index) => {
51
+ if (err) {
52
+ log.error("Cache invalidation index creation failed: " + err.message);
53
+ } else {
54
+ log.debug("Cache invalidation index created: " + index);
55
+ }
56
+ });
57
+ model.mongo.createIndex(cache_invalidation, { 'expiresAt': 1 }, { expireAfterSeconds: 0 }, (err, index) => {
58
+ if (err) {
59
+ log.error("Cache invalidation TTL index creation failed: " + err.message);
60
+ } else {
61
+ log.debug("Cache invalidation TTL index created: " + index);
62
+ }
63
+ });
38
64
  }
39
65
  },
40
66
 
@@ -58,7 +84,15 @@ let model = {
58
84
  }
59
85
  },
60
86
 
61
- 'set': async (key, response, ttl) => {
87
+ /**
88
+ * @param key {l1, l2}
89
+ * @param response {statusCode, headers, body}
90
+ * @param ttl
91
+ * @param meta {service, api, user, startedAt} what an invalidation matches on, user is null for a
92
+ * tenant scoped entry
93
+ * @returns {Promise<boolean>} false when not stored
94
+ */
95
+ 'set': async (key, response, ttl, meta) => {
62
96
  let condition = {
63
97
  'l1': key.l1,
64
98
  'l2': key.l2
@@ -70,13 +104,73 @@ let model = {
70
104
  'cachedAt': new Date(),
71
105
  'expiresAt': new Date(Date.now() + ttl)
72
106
  };
107
+ if (meta) {
108
+ doc.service = meta.service;
109
+ doc.api = meta.api;
110
+ doc.user = meta.user || null;
111
+ }
73
112
  let options = { 'upsert': true };
74
113
  let s = { '$set': doc };
75
114
  try {
115
+ //NOTE: a request that started before an invalidation covering it may have read the data
116
+ // before the change, its response is passed on but not stored
117
+ if (meta && meta.startedAt) {
118
+ let invalidated = await model.mongo.findOne(cache_invalidation, {
119
+ 'l1': key.l1,
120
+ 'service': meta.service,
121
+ 'api': { '$in': [meta.api, null] },
122
+ 'user': { '$in': [doc.user, null] },
123
+ 'clearedAt': { '$gt': new Date(meta.startedAt) }
124
+ });
125
+ if (invalidated) {
126
+ return false;
127
+ }
128
+ }
76
129
  await model.mongo.updateOne(cache_store, condition, s, options);
130
+ return true;
77
131
  } catch (e) {
78
132
  model.log.error('Cache set:', e.message);
133
+ return false;
134
+ }
135
+ },
136
+
137
+ /**
138
+ * Remove every cached variant of the matching entries, all query strings and path params.
139
+ *
140
+ * NOTE: the invalidation is recorded before the entries are removed, a request that read before the
141
+ * change and stores after the removal is caught by set
142
+ *
143
+ * @param criteria {tenant, service, api, user} api and user are optional, without them every api of
144
+ * the service, for every user and the tenant scoped entries
145
+ * @returns {Promise<number>} the number of entries removed
146
+ */
147
+ 'invalidateMatching': async (criteria) => {
148
+ let marker = {
149
+ 'l1': criteria.tenant,
150
+ 'service': criteria.service,
151
+ 'api': criteria.api || null,
152
+ 'user': criteria.user || null
153
+ };
154
+ let now = new Date();
155
+ await model.mongo.updateOne(cache_invalidation, marker, {
156
+ '$set': Object.assign({}, marker, {
157
+ 'clearedAt': now,
158
+ 'expiresAt': new Date(now.getTime() + INVALIDATION_RETENTION)
159
+ })
160
+ }, { 'upsert': true });
161
+
162
+ let condition = {
163
+ 'l1': criteria.tenant,
164
+ 'service': criteria.service
165
+ };
166
+ if (criteria.api) {
167
+ condition.api = criteria.api;
168
+ }
169
+ if (criteria.user) {
170
+ condition.user = criteria.user;
79
171
  }
172
+ let response = await model.mongo.deleteMany(cache_store, condition);
173
+ return (response && response.deletedCount) || 0;
80
174
  },
81
175
 
82
176
  'invalidate': async (key) => {
@@ -411,7 +411,7 @@ module.exports = (configuration) => {
411
411
  headers: cacheResponseHeaders,
412
412
  body: cacheBody
413
413
  };
414
- cacheContext.model.set(cacheContext.key, cacheData, cacheContext.ttl);
414
+ cacheContext.model.set(cacheContext.key, cacheData, cacheContext.ttl, cacheContext.meta);
415
415
  }
416
416
  // Complete idempotency if applicable
417
417
  //NOTE: only an answer of the service is replayed, a 5xx means it could not answer, release
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "soajs.controller",
3
3
  "description": "soajs multi tenant API gateway",
4
- "version": "4.3.19",
4
+ "version": "4.3.21",
5
5
  "author": {
6
6
  "name": "soajs team",
7
7
  "email": "team@soajs.org"
@@ -11,6 +11,7 @@
11
11
  const get = (p, o) => p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o);
12
12
 
13
13
  const registryModule = require("./../modules/registry");
14
+ const cacheInvalidate = require("./../mw/cache/invalidate.js");
14
15
 
15
16
  const httpProxy = require('http-proxy');
16
17
  const http = require('http');
@@ -311,6 +312,46 @@ let Maintenance = (core, log, param, serviceIp, regEnvironment, awareness_mw, so
311
312
  };
312
313
  proxy.web(req, res, {target: haTarget});
313
314
  }
315
+ else if (parsedUrl.pathname === '/cache/invalidate') {
316
+ let response = maintenanceResponse(parsedUrl, param);
317
+ let reply = (status, extra) => {
318
+ res.writeHead(status, {'Content-Type': 'application/json'});
319
+ return res.end(JSON.stringify(Object.assign(response, extra)));
320
+ };
321
+ if (req.method !== 'POST') {
322
+ return reply(405, {"error": "Use POST."});
323
+ }
324
+ let body = "";
325
+ let tooLarge = false;
326
+ req.on('data', function (chunk) {
327
+ body += chunk;
328
+ //NOTE: the body only names what to remove, anything larger is not a valid request
329
+ if (body.length > 16384) {
330
+ tooLarge = true;
331
+ req.destroy();
332
+ }
333
+ });
334
+ req.on('end', function () {
335
+ if (tooLarge) {
336
+ return;
337
+ }
338
+ let input = null;
339
+ try {
340
+ input = body ? JSON.parse(body) : null;
341
+ } catch (e) {
342
+ return reply(400, {"error": "The body is not valid JSON."});
343
+ }
344
+ cacheInvalidate(registryModule.get(), input).then((outcome) => {
345
+ if (outcome.status !== 200) {
346
+ return reply(outcome.status, {"error": outcome.error});
347
+ }
348
+ log.info("Cache invalidated for tenant [" + input.tenant + "] service [" + input.service + "]" +
349
+ (input.api ? " api [" + input.api + "]" : "") + (input.user ? " user [" + input.user + "]" : "") +
350
+ ", removed [" + outcome.removed + "]");
351
+ return reply(200, {"result": true, "data": {"removed": outcome.removed}});
352
+ });
353
+ });
354
+ }
314
355
  else if (parsedUrl.pathname === '/loadProvision') {
315
356
  provision.loadProvision(function (loaded) {
316
357
  let response = maintenanceResponse(parsedUrl, param);
@@ -16,6 +16,21 @@ let doc = {
16
16
  }
17
17
  }
18
18
  },
19
+ "cache": {
20
+ "model": "mongo",
21
+ "defaultTTL": 60000,
22
+ "services": {
23
+ "spaces": {
24
+ "enabled": true,
25
+ "apis": {
26
+ "GET /spaces": { "enabled": true, "scope": "tenant_user" },
27
+ "GET /space/:id": { "enabled": true, "scope": "tenant_user" },
28
+ "GET /slow": { "enabled": true, "scope": "tenant_user" },
29
+ "GET /catalog": { "enabled": true, "scope": "tenant" }
30
+ }
31
+ }
32
+ }
33
+ },
19
34
  "traffic": {
20
35
  "throttling": {
21
36
  "idempotent": {
@@ -0,0 +1,12 @@
1
+ 'use strict';
2
+
3
+ let host = {
4
+ _id: "5da078e22357684b57effcf5",
5
+ env: "dev",
6
+ ip: "127.0.0.1",
7
+ name: "spaces",
8
+ version: "1",
9
+ port: 4015
10
+ };
11
+
12
+ module.exports = host;
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ let service = {
4
+ _id: "5da078c92357684b57effcf4",
5
+ "type": "service",
6
+ name: "spaces",
7
+ configuration: {
8
+ group: "SOAJS test",
9
+ port: 4015,
10
+ requestTimeout: 30,
11
+ requestTimeoutRenewal: 5
12
+ },
13
+ versions: [{
14
+ "version": "1",
15
+ maintenance: {
16
+ "port": {
17
+ "type": "maintenance"
18
+ },
19
+ "readiness": "/heartbeat"
20
+ },
21
+ oauth: true,
22
+ extKeyRequired: true,
23
+ urac: true,
24
+ urac_Profile: false,
25
+ urac_ACL: false,
26
+ provision_ACL: false,
27
+ apis: [
28
+ { l: "Spaces of the user", v: "/spaces", m: "get", group: "Integration test" },
29
+ { l: "One space", v: "/space/:id", m: "get", group: "Integration test" },
30
+ { l: "Spaces after a delay", v: "/slow", m: "get", group: "Integration test" },
31
+ { l: "Same for the whole tenant", v: "/catalog", m: "get", group: "Integration test" }
32
+ ]
33
+ }
34
+ ]
35
+ };
36
+ module.exports = service;
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ let token = {
4
+ _id: "5cdc190fd52c82e0ddb1dcd8",
5
+ type: "accessToken",
6
+ token: "c4c4e5e5f6f6a7a7b8b8c9c9d0d0e1e1f2f2a301",
7
+ clientId: "5c0e74ba9acc3c5a84a51259",
8
+ user: {
9
+ _id: "5c8d0c505653de3985aa2001",
10
+ locked: true,
11
+ username: "second_owner",
12
+ firstName: "owner3'sèį",
13
+ lastName: "owner",
14
+ email: "me@localhost.com",
15
+ "ts": new Date().getTime(),
16
+ status: "active",
17
+ profile: {},
18
+ groups: [
19
+ "owner"
20
+ ],
21
+ config: {
22
+ packages: {},
23
+ keys: {},
24
+ allowedTenants: [
25
+ {
26
+ tenant: {
27
+ id: "THYME_tID",
28
+ code: "THYME_CODE",
29
+ pin: {
30
+ code: "5678",
31
+ allowed: true
32
+ }
33
+ },
34
+ groups: [
35
+ "waiter"
36
+ ]
37
+ },
38
+ {
39
+ tenant: {
40
+ id: "ELVIRA_tID",
41
+ code: "ELVIRA_CODE"
42
+ },
43
+ groups: [
44
+ "manager"
45
+ ]
46
+ }
47
+ ]
48
+ },
49
+ tenant: {
50
+ id: "5c0e74ba9acc3c5a84a51259",
51
+ code: "DBTN",
52
+ pin: {
53
+ code: "1235",
54
+ allowed: true
55
+ }
56
+ },
57
+ lastLogin: new Date().getTime(),
58
+ groupsConfig: {
59
+ allowedPackages: {
60
+ DSBRD: [
61
+ "DSBRD_OWNER"
62
+ ]
63
+ },
64
+ allowedEnvironments: {}
65
+ },
66
+ loginMode: "urac",
67
+ id: "5c8d0c505653de3985aa2001"
68
+ },
69
+ env: "dev",
70
+ expires: new Date((new Date().getFullYear()) + 2, 0, 1)
71
+ };
72
+ module.exports = token;
@@ -9,6 +9,22 @@ let prod = {
9
9
  "scope": {
10
10
  "acl": {
11
11
  "dev": {
12
+ "spaces": {
13
+ "1": {
14
+ "access": true,
15
+ "get": [
16
+ {
17
+ "group": "Spaces group",
18
+ "apis": {
19
+ "/spaces": {},
20
+ "/space/:id": {},
21
+ "/slow": {},
22
+ "/catalog": {}
23
+ }
24
+ }
25
+ ]
26
+ }
27
+ },
12
28
  "idempotent": {
13
29
  "1": {
14
30
  "access": true,
@@ -217,6 +233,14 @@ let prod = {
217
233
  "locked": true,
218
234
  "acl": {
219
235
  "dev": {
236
+ "spaces": [
237
+ {
238
+ "version": "1",
239
+ "get": [
240
+ "Spaces group"
241
+ ]
242
+ }
243
+ ],
220
244
  "idempotent": [
221
245
  {
222
246
  "version": "1",
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+ let user = {
3
+ _id: "5c8d0c505653de3985aa2001",
4
+ locked: true,
5
+ username: "second_owner",
6
+ password: "$2a$12$geJJfv33wkYIXEAlDkeeuOgiQ6y6MjP/YxbqLdHdDSK7LDG.7n7Pq",
7
+ firstName: "owner3'sèį",
8
+ lastName: "owner",
9
+ email: "second_owner@localhost.com",
10
+ ts: 1552747600152,
11
+ status: "active",
12
+ profile: {
13
+
14
+ },
15
+ groups: [
16
+ "owner"
17
+ ],
18
+ config: {
19
+ packages: {
20
+
21
+ },
22
+ keys: {
23
+
24
+ },
25
+ allowedTenants: [
26
+ {
27
+ tenant: {
28
+ id: "THYME_tID",
29
+ code: "THYME_CODE",
30
+ pin: {
31
+ code: "5678",
32
+ allowed: true
33
+ }
34
+ },
35
+ groups: [
36
+ "waiter"
37
+ ]
38
+ },
39
+ {
40
+ tenant: {
41
+ id: "ELVIRA_tID",
42
+ code: "ELVIRA_CODE"
43
+ },
44
+ groups: [
45
+ "manager"
46
+ ]
47
+ }
48
+ ]
49
+ },
50
+ tenant: {
51
+ id: "5c0e74ba9acc3c5a84a51259",
52
+ code: "DBTN",
53
+ pin: {
54
+ code: "1235",
55
+ allowed: true
56
+ }
57
+ },
58
+ lastLogin: 1559836949372
59
+ };
60
+
61
+ module.exports = user;
@@ -0,0 +1,209 @@
1
+ "use strict";
2
+
3
+ const assert = require('assert');
4
+ const express = require('express');
5
+ const request = require('request');
6
+ const helper = require("../../helper.js");
7
+ let Controller = helper.requireModule('./server/controller');
8
+ let localConfig = helper.requireModule('./config.js');
9
+
10
+ //NOTE: the gateway custom registry caches the spaces service with the mongo model, per user except
11
+ // /catalog which is per tenant. Both tokens are users of the console tenant.
12
+ const tenantId = "5c0e74ba9acc3c5a84a51259";
13
+ const ownerToken = "cfb209a91b23896820f510aadbf1f4284b512123";
14
+ const ownerId = "5c8d0c505653de3985aa0ffd";
15
+ const secondToken = "c4c4e5e5f6f6a7a7b8b8c9c9d0d0e1e1f2f2a301";
16
+ const secondId = "5c8d0c505653de3985aa2001";
17
+ const maintenanceUri = 'http://127.0.0.1:5001/cache/invalidate';
18
+
19
+ function startService(calls, callback) {
20
+ const sApp = express();
21
+ const mApp = express();
22
+ let answer = (name) => (req, res) => {
23
+ calls[name] = (calls[name] || 0) + 1;
24
+ res.json({ "result": true, "data": { "call": calls[name], "url": req.url } });
25
+ };
26
+ sApp.get('/spaces', answer("spaces"));
27
+ sApp.get('/space/:id', answer("space"));
28
+ sApp.get('/catalog', answer("catalog"));
29
+ sApp.get('/slow', (req, res) => {
30
+ calls.slow = (calls.slow || 0) + 1;
31
+ let call = calls.slow;
32
+ setTimeout(() => {
33
+ res.json({ "result": true, "data": { "call": call } });
34
+ }, 1000);
35
+ });
36
+ mApp.get('/heartbeat', (req, res) => {
37
+ res.json({ "result": true, "ts": Date.now(), "service": { "service": "spaces", "type": "rest", "route": "/heartbeat" } });
38
+ });
39
+ let sAppServer = sApp.listen(4015, () => {
40
+ let mAppServer = mApp.listen(5016, () => {
41
+ return callback({ "sAppServer": sAppServer, "mAppServer": mAppServer });
42
+ });
43
+ });
44
+ }
45
+
46
+ describe("Integration for Usecase 7 - cache invalidation", function () {
47
+ let extKey = "3d90163cf9d6b3076ad26aa5ed58556348069258e5c6c941ee0f18448b570ad1c5c790e2d2a1989680c55f4904e2005ff5f8e71606e4aa641e67882f4210ebbc5460ff305dcb36e6ec2a2299cf0448ef60b9e38f41950ec251c1cf41f05f3ce9";
48
+
49
+ let c = new Controller({
50
+ "serviceName": localConfig.serviceName,
51
+ "serviceVersion": localConfig.serviceVersion,
52
+ "serviceGroup": localConfig.serviceGroup,
53
+ "servicePort": localConfig.servicePort,
54
+ "maintenance": localConfig.maintenance
55
+ });
56
+ let registry = null;
57
+ let log = null;
58
+ let service = null;
59
+ let server = null;
60
+ let serverMaintenance = null;
61
+ let mockServers = null;
62
+ let calls = {};
63
+
64
+ // GET through the gateway, resolves with the X-Cache header and the body
65
+ function get(path, token) {
66
+ return new Promise((resolve) => {
67
+ request.get({
68
+ "uri": 'http://127.0.0.1:' + localConfig.servicePort + '/spaces' + path,
69
+ "json": true,
70
+ "headers": {
71
+ "key": extKey,
72
+ "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2227.1 Safari/537.36"
73
+ },
74
+ "qs": { "access_token": token }
75
+ }, (error, response, body) => {
76
+ assert.ifError(error);
77
+ resolve({ "cache": response.headers['x-cache'], "status": response.statusCode, body });
78
+ });
79
+ });
80
+ }
81
+
82
+ function invalidate(body, method) {
83
+ return new Promise((resolve) => {
84
+ request({
85
+ "method": method || 'POST',
86
+ "uri": maintenanceUri,
87
+ "json": body === undefined ? true : body
88
+ }, (error, response, responseBody) => {
89
+ assert.ifError(error);
90
+ resolve({ "status": response.statusCode, "body": responseBody });
91
+ });
92
+ });
93
+ }
94
+
95
+ // the response is stored once the proxy finishes, after the client got it
96
+ const settle = () => new Promise((r) => setTimeout(r, 150));
97
+
98
+ before((done) => {
99
+ //NOTE: the unit tests run in the same process and connect the cache mongo model to a dummy
100
+ // database, the model connects only once, drop it so the gateway connects it to the real one
101
+ helper.requireModule('./mw/cache/model/mongo').mongo = null;
102
+ startService(calls, (servers) => {
103
+ mockServers = servers;
104
+ c.init((_registry, _log, _service, _server, _serverMaintenance) => {
105
+ registry = _registry;
106
+ log = _log;
107
+ service = _service;
108
+ server = _server;
109
+ serverMaintenance = _serverMaintenance;
110
+ c.start(registry, log, service, server, serverMaintenance, () => {
111
+ setTimeout(function () {
112
+ done();
113
+ }, 5000);
114
+ });
115
+ });
116
+ });
117
+ });
118
+
119
+ it("a cached response is served from the cache", async () => {
120
+ let first = await get('/spaces', ownerToken);
121
+ assert.strictEqual(first.status, 200, JSON.stringify(first.body));
122
+ assert.strictEqual(first.cache, 'MISS');
123
+ await settle();
124
+ let second = await get('/spaces', ownerToken);
125
+ assert.strictEqual(second.cache, 'HIT');
126
+ assert.strictEqual(second.body.data.call, first.body.data.call);
127
+ });
128
+
129
+ it("one user: every query variant of the api is removed, the other user keeps theirs", async () => {
130
+ await get('/spaces?page=1', ownerToken);
131
+ await get('/spaces?page=2', ownerToken);
132
+ await get('/spaces', secondToken);
133
+ await settle();
134
+ assert.strictEqual((await get('/spaces?page=1', ownerToken)).cache, 'HIT');
135
+ assert.strictEqual((await get('/spaces', secondToken)).cache, 'HIT');
136
+
137
+ let outcome = await invalidate({ "tenant": tenantId, "service": "spaces", "api": "GET /spaces", "user": ownerId });
138
+ assert.strictEqual(outcome.status, 200, JSON.stringify(outcome.body));
139
+ assert.strictEqual(outcome.body.result, true);
140
+ assert.strictEqual(outcome.body.data.removed, 3);
141
+
142
+ assert.strictEqual((await get('/spaces', ownerToken)).cache, 'MISS');
143
+ assert.strictEqual((await get('/spaces?page=1', ownerToken)).cache, 'MISS');
144
+ assert.strictEqual((await get('/spaces?page=2', ownerToken)).cache, 'MISS');
145
+ assert.strictEqual((await get('/spaces', secondToken)).cache, 'HIT');
146
+ });
147
+
148
+ it("without a user every user's entries are removed", async () => {
149
+ await settle();
150
+ assert.strictEqual((await get('/spaces', ownerToken)).cache, 'HIT');
151
+ assert.strictEqual((await get('/spaces', secondToken)).cache, 'HIT');
152
+ let outcome = await invalidate({ "tenant": tenantId, "service": "spaces", "api": "GET /spaces" });
153
+ assert.strictEqual(outcome.status, 200);
154
+ assert.ok(outcome.body.data.removed >= 2);
155
+ assert.strictEqual((await get('/spaces', ownerToken)).cache, 'MISS');
156
+ assert.strictEqual((await get('/spaces', secondToken)).cache, 'MISS');
157
+ });
158
+
159
+ it("an api with a path param is removed for every value", async () => {
160
+ await get('/space/1', ownerToken);
161
+ await get('/space/2', ownerToken);
162
+ await settle();
163
+ assert.strictEqual((await get('/space/1', ownerToken)).cache, 'HIT');
164
+ let outcome = await invalidate({ "tenant": tenantId, "service": "spaces", "api": "GET /space/:id", "user": ownerId });
165
+ assert.strictEqual(outcome.body.data.removed, 2);
166
+ assert.strictEqual((await get('/space/1', ownerToken)).cache, 'MISS');
167
+ assert.strictEqual((await get('/space/2', ownerToken)).cache, 'MISS');
168
+ });
169
+
170
+ it("a tenant scoped entry is shared by users, and removed without a user", async () => {
171
+ await get('/catalog', ownerToken);
172
+ await settle();
173
+ assert.strictEqual((await get('/catalog', secondToken)).cache, 'HIT');
174
+ let outcome = await invalidate({ "tenant": tenantId, "service": "spaces", "api": "GET /catalog" });
175
+ assert.strictEqual(outcome.body.data.removed, 1);
176
+ assert.strictEqual((await get('/catalog', secondToken)).cache, 'MISS');
177
+ });
178
+
179
+ it("a request that started before the invalidation is not stored", async () => {
180
+ let inFlight = get('/slow', ownerToken);
181
+ await new Promise((r) => setTimeout(r, 300));
182
+ let outcome = await invalidate({ "tenant": tenantId, "service": "spaces", "api": "GET /slow", "user": ownerId });
183
+ assert.strictEqual(outcome.status, 200);
184
+ let first = await inFlight;
185
+ assert.strictEqual(first.status, 200);
186
+ await settle();
187
+ let next = await get('/slow', ownerToken);
188
+ assert.strictEqual(next.cache, 'MISS');
189
+ assert.strictEqual(next.body.data.call, first.body.data.call + 1);
190
+ await settle();
191
+ assert.strictEqual((await get('/slow', ownerToken)).cache, 'HIT');
192
+ });
193
+
194
+ it("refuses an invalid request", async () => {
195
+ assert.strictEqual((await invalidate({ "service": "spaces" })).status, 400);
196
+ assert.strictEqual((await invalidate({ "tenant": tenantId, "service": "spaces", "api": "/spaces" })).status, 400);
197
+ let wrongMethod = await invalidate(undefined, 'GET');
198
+ assert.strictEqual(wrongMethod.status, 405);
199
+ assert.strictEqual(wrongMethod.body.result, false);
200
+ });
201
+
202
+ after((done) => {
203
+ mockServers.sAppServer.close();
204
+ mockServers.mAppServer.close();
205
+ c.stop(registry, log, service, server, serverMaintenance, () => {
206
+ done();
207
+ });
208
+ });
209
+ });
@@ -44,6 +44,10 @@ describe("starting integration tests", () => {
44
44
  // to test idempotency with the mongo model
45
45
  require("./UC6-idempotency/index.js");
46
46
 
47
+
48
+ // to test cache invalidation with the mongo model
49
+ require("./UC7-cache-invalidate/index.js");
50
+
47
51
  done();
48
52
  });
49
53
  });
@@ -42,6 +42,8 @@ describe("Starting Gateway Unit test", () => {
42
42
  require("./mw/idempotency/mongo.js");
43
43
  require("./mw/cache/index.js");
44
44
  require("./mw/cache/memory.js");
45
+ require("./mw/cache/mongo.js");
46
+ require("./mw/cache/invalidate.js");
45
47
  require("./mw/lastSeen/index.js");
46
48
 
47
49
  require("./utilities/utils.js");
@@ -575,4 +575,126 @@ describe("Unit test for: mw - cache", () => {
575
575
  });
576
576
  });
577
577
 
578
+ describe("what an invalidation matches on", () => {
579
+ let noopRes = {
580
+ setHeader: function() {},
581
+ writeHead: function() { return this; },
582
+ write: function() { return true; },
583
+ end: function() {}
584
+ };
585
+
586
+ function buildReq(path, isAPIPublic, apis) {
587
+ return {
588
+ method: 'GET',
589
+ query: { page: '2' },
590
+ soajs: {
591
+ tenant: { id: 'tenant-meta' },
592
+ uracDriver: { id: 'user-7' },
593
+ controller: {
594
+ serviceParams: { name: 'spaces', serviceInfo: ['', 'spaces'].concat(path.split('/').slice(1)), isAPIPublic: isAPIPublic }
595
+ },
596
+ registry: {
597
+ custom: {
598
+ gateway: {
599
+ value: {
600
+ cache: {
601
+ model: 'memory',
602
+ services: {
603
+ spaces: { enabled: true, apis: apis }
604
+ }
605
+ }
606
+ }
607
+ }
608
+ }
609
+ }
610
+ }
611
+ };
612
+ }
613
+
614
+ it("records the service, the api as configured and the user", (done) => {
615
+ let before = Date.now();
616
+ let req = buildReq('/space/42', false, { 'GET /space/:id': { enabled: true } });
617
+ mw(mockConfig)(req, noopRes, (error) => {
618
+ assert.ifError(error);
619
+ let meta = req.soajs.cacheContext.meta;
620
+ assert.strictEqual(meta.service, 'spaces');
621
+ assert.strictEqual(meta.api, 'GET /space/:id');
622
+ assert.strictEqual(meta.user, 'user-7');
623
+ assert.ok(meta.startedAt >= before && meta.startedAt <= Date.now());
624
+ done();
625
+ });
626
+ });
627
+
628
+ it("records no user for a tenant scoped api", (done) => {
629
+ let req = buildReq('/catalog', false, { 'GET /catalog': { enabled: true, scope: 'tenant' } });
630
+ mw(mockConfig)(req, noopRes, (error) => {
631
+ assert.ifError(error);
632
+ assert.strictEqual(req.soajs.cacheContext.meta.api, 'GET /catalog');
633
+ assert.strictEqual(req.soajs.cacheContext.meta.user, null);
634
+ done();
635
+ });
636
+ });
637
+ });
638
+
639
+ describe("the access token is not part of the key", () => {
640
+ let noopRes = {
641
+ setHeader: function() {},
642
+ writeHead: function() { return this; },
643
+ write: function() { return true; },
644
+ end: function() {}
645
+ };
646
+
647
+ function buildReq(query, userId) {
648
+ return {
649
+ method: 'GET',
650
+ query: query,
651
+ soajs: {
652
+ tenant: { id: 'tenant-token' },
653
+ uracDriver: { id: userId },
654
+ controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'catalog'], isAPIPublic: true } },
655
+ registry: {
656
+ custom: {
657
+ gateway: {
658
+ value: {
659
+ cache: {
660
+ model: 'memory',
661
+ services: {
662
+ test: { enabled: true, apis: { 'GET /catalog': { enabled: true, scope: 'tenant' } } }
663
+ }
664
+ }
665
+ }
666
+ }
667
+ }
668
+ }
669
+ }
670
+ };
671
+ }
672
+
673
+ function keyOf(query, userId) {
674
+ return new Promise((resolve) => {
675
+ let req = buildReq(query, userId);
676
+ mw(mockConfig)(req, noopRes, () => {
677
+ resolve(req.soajs.cacheContext.key.l2);
678
+ });
679
+ });
680
+ }
681
+
682
+ it("two users with their own tokens share a tenant scoped entry", async () => {
683
+ let ann = await keyOf({ access_token: 'AAA', page: '1' }, 'ann');
684
+ let bob = await keyOf({ access_token: 'BBB', page: '1' }, 'bob');
685
+ assert.strictEqual(ann, bob);
686
+ });
687
+
688
+ it("a token alone gives the same key as no query", async () => {
689
+ let withToken = await keyOf({ access_token: 'AAA' }, 'ann');
690
+ let without = await keyOf({}, 'ann');
691
+ assert.strictEqual(withToken, without);
692
+ });
693
+
694
+ it("the other query params still make the key", async () => {
695
+ let one = await keyOf({ access_token: 'AAA', page: '1' }, 'ann');
696
+ let two = await keyOf({ access_token: 'AAA', page: '2' }, 'ann');
697
+ assert.notStrictEqual(one, two);
698
+ });
699
+ });
578
700
  });
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+
3
+ const helper = require("../../../helper.js");
4
+ const invalidate = helper.requireModule('./mw/cache/invalidate');
5
+ const model = helper.requireModule('./mw/cache/model/mongo');
6
+ const assert = require('assert');
7
+
8
+ describe("Unit test for: mw - cache invalidate", () => {
9
+
10
+ let received = null;
11
+ let failure = null;
12
+ let originalInvalidateMatching = null;
13
+ let originalMongo = null;
14
+
15
+ function registry(model) {
16
+ return { custom: { gateway: { value: { cache: { model: model, services: {} } } } } };
17
+ }
18
+
19
+ before(() => {
20
+ originalInvalidateMatching = model.invalidateMatching;
21
+ originalMongo = model.mongo;
22
+ model.mongo = {};
23
+ model.invalidateMatching = async (criteria) => {
24
+ received = criteria;
25
+ if (failure) {
26
+ throw failure;
27
+ }
28
+ return 2;
29
+ };
30
+ });
31
+ after(() => {
32
+ model.invalidateMatching = originalInvalidateMatching;
33
+ model.mongo = originalMongo;
34
+ });
35
+ beforeEach(() => {
36
+ received = null;
37
+ failure = null;
38
+ });
39
+
40
+ it("removes the matching entries", async () => {
41
+ let outcome = await invalidate(registry('mongo'), { tenant: 't1', service: 'spaces', api: 'GET /spaces', user: 'u1' });
42
+ assert.deepStrictEqual(outcome, { status: 200, removed: 2 });
43
+ assert.deepStrictEqual(received, { tenant: 't1', service: 'spaces', api: 'GET /spaces', user: 'u1' });
44
+ });
45
+
46
+ it("api and user are optional", async () => {
47
+ let outcome = await invalidate(registry('mongo'), { tenant: 't1', service: 'spaces' });
48
+ assert.strictEqual(outcome.status, 200);
49
+ assert.deepStrictEqual(received, { tenant: 't1', service: 'spaces', api: undefined, user: undefined });
50
+ });
51
+
52
+ it("refuses the memory model", async () => {
53
+ let outcome = await invalidate(registry('memory'), { tenant: 't1', service: 'spaces' });
54
+ assert.strictEqual(outcome.status, 400);
55
+ assert.ok(outcome.error.indexOf('mongo') !== -1);
56
+ assert.strictEqual(received, null);
57
+ });
58
+
59
+ it("refuses when no model is set, memory is the default", async () => {
60
+ let outcome = await invalidate(registry(undefined), { tenant: 't1', service: 'spaces' });
61
+ assert.strictEqual(outcome.status, 400);
62
+ assert.strictEqual(received, null);
63
+ });
64
+
65
+ it("refuses when caching is not configured", async () => {
66
+ let outcome = await invalidate({ custom: {} }, { tenant: 't1', service: 'spaces' });
67
+ assert.strictEqual(outcome.status, 400);
68
+ });
69
+
70
+ it("requires tenant and service", async () => {
71
+ assert.strictEqual((await invalidate(registry('mongo'), null)).status, 400);
72
+ assert.strictEqual((await invalidate(registry('mongo'), { service: 'spaces' })).status, 400);
73
+ assert.strictEqual((await invalidate(registry('mongo'), { tenant: 't1' })).status, 400);
74
+ assert.strictEqual(received, null);
75
+ });
76
+
77
+ it("requires the api as written in the configuration", async () => {
78
+ assert.strictEqual((await invalidate(registry('mongo'), { tenant: 't1', service: 'spaces', api: '/spaces' })).status, 400);
79
+ assert.strictEqual((await invalidate(registry('mongo'), { tenant: 't1', service: 'spaces', api: 'POST /spaces' })).status, 400);
80
+ assert.strictEqual((await invalidate(registry('mongo'), { tenant: 't1', service: 'spaces', user: 42 })).status, 400);
81
+ assert.strictEqual(received, null);
82
+ });
83
+
84
+ it("answers 500 when the store fails", async () => {
85
+ failure = new Error("down");
86
+ let outcome = await invalidate(registry('mongo'), { tenant: 't1', service: 'spaces' });
87
+ assert.strictEqual(outcome.status, 500);
88
+ });
89
+
90
+ it("answers 500 when the store is not ready", async () => {
91
+ model.mongo = null;
92
+ try {
93
+ let outcome = await invalidate(registry('mongo'), { tenant: 't1', service: 'spaces' });
94
+ assert.strictEqual(outcome.status, 500);
95
+ } finally {
96
+ model.mongo = {};
97
+ }
98
+ });
99
+ });
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+
3
+ const helper = require("../../../helper.js");
4
+ const model = helper.requireModule('./mw/cache/model/mongo');
5
+ const assert = require('assert');
6
+
7
+ describe("Unit test for: mw - cache/model/mongo invalidation", () => {
8
+
9
+ let calls = null;
10
+ let invalidation = null;
11
+ let deleted = 0;
12
+ let originalMongo = null;
13
+ let originalLog = null;
14
+
15
+ before(() => {
16
+ originalMongo = model.mongo;
17
+ originalLog = model.log;
18
+ model.log = {
19
+ debug: () => {},
20
+ info: () => {},
21
+ error: () => {}
22
+ };
23
+ model.mongo = {
24
+ findOne: async (col, condition) => {
25
+ calls.push({ "op": "findOne", col, condition });
26
+ return invalidation;
27
+ },
28
+ updateOne: async (col, condition, s, options) => {
29
+ calls.push({ "op": "updateOne", col, condition, s, options });
30
+ },
31
+ deleteMany: async (col, condition) => {
32
+ calls.push({ "op": "deleteMany", col, condition });
33
+ return { deletedCount: deleted };
34
+ }
35
+ };
36
+ });
37
+ after(() => {
38
+ model.mongo = originalMongo;
39
+ model.log = originalLog;
40
+ });
41
+ beforeEach(() => {
42
+ calls = [];
43
+ invalidation = null;
44
+ deleted = 0;
45
+ });
46
+
47
+ let key = { l1: 'tenant1', l2: 'spaces:GET:/spaces::u:user-1' };
48
+ let response = { statusCode: 200, headers: {}, body: '[]' };
49
+
50
+ it("set stores the fields an invalidation matches on", async () => {
51
+ let stored = await model.set(key, response, 60000, { service: 'spaces', api: 'GET /spaces', user: 'user-1', startedAt: Date.now() });
52
+ assert.strictEqual(stored, true);
53
+ let update = calls.find((c) => c.op === "updateOne");
54
+ assert.strictEqual(update.col, 'cache_store');
55
+ assert.strictEqual(update.s.$set.service, 'spaces');
56
+ assert.strictEqual(update.s.$set.api, 'GET /spaces');
57
+ assert.strictEqual(update.s.$set.user, 'user-1');
58
+ });
59
+
60
+ it("set looks for an invalidation after the request started, for that api or all, that user or all", async () => {
61
+ let startedAt = Date.now();
62
+ await model.set(key, response, 60000, { service: 'spaces', api: 'GET /spaces', user: 'user-1', startedAt: startedAt });
63
+ let lookup = calls.find((c) => c.op === "findOne");
64
+ assert.strictEqual(lookup.col, 'cache_invalidation');
65
+ assert.deepStrictEqual(lookup.condition, {
66
+ l1: 'tenant1',
67
+ service: 'spaces',
68
+ api: { $in: ['GET /spaces', null] },
69
+ user: { $in: ['user-1', null] },
70
+ clearedAt: { $gt: new Date(startedAt) }
71
+ });
72
+ });
73
+
74
+ it("set does not store a response read before an invalidation", async () => {
75
+ invalidation = { clearedAt: new Date() };
76
+ let stored = await model.set(key, response, 60000, { service: 'spaces', api: 'GET /spaces', user: 'user-1', startedAt: Date.now() - 1000 });
77
+ assert.strictEqual(stored, false);
78
+ assert.strictEqual(calls.filter((c) => c.op === "updateOne").length, 0);
79
+ });
80
+
81
+ it("set without meta stores as before", async () => {
82
+ let stored = await model.set(key, response, 60000);
83
+ assert.strictEqual(stored, true);
84
+ assert.strictEqual(calls.filter((c) => c.op === "findOne").length, 0);
85
+ });
86
+
87
+ it("invalidateMatching records the invalidation before removing", async () => {
88
+ deleted = 3;
89
+ let removed = await model.invalidateMatching({ tenant: 'tenant1', service: 'spaces', api: 'GET /spaces', user: 'user-1' });
90
+ assert.strictEqual(removed, 3);
91
+ assert.deepStrictEqual(calls.map((c) => c.op), ['updateOne', 'deleteMany']);
92
+ assert.strictEqual(calls[0].col, 'cache_invalidation');
93
+ assert.deepStrictEqual(calls[0].condition, { l1: 'tenant1', service: 'spaces', api: 'GET /spaces', user: 'user-1' });
94
+ assert.ok(calls[0].s.$set.clearedAt instanceof Date);
95
+ assert.ok(calls[0].s.$set.expiresAt > calls[0].s.$set.clearedAt);
96
+ assert.deepStrictEqual(calls[1].condition, { l1: 'tenant1', service: 'spaces', api: 'GET /spaces', user: 'user-1' });
97
+ });
98
+
99
+ it("invalidateMatching without api and user covers the whole service", async () => {
100
+ await model.invalidateMatching({ tenant: 'tenant1', service: 'spaces' });
101
+ assert.deepStrictEqual(calls[0].condition, { l1: 'tenant1', service: 'spaces', api: null, user: null });
102
+ assert.deepStrictEqual(calls[1].condition, { l1: 'tenant1', service: 'spaces' });
103
+ });
104
+ });