@prosopo/user-access-policy 3.6.0 → 3.7.11

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.
Files changed (145) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +21 -19
  2. package/.turbo/turbo-build$colon$tsc.log +16 -13
  3. package/.turbo/turbo-build.log +22 -20
  4. package/CHANGELOG.md +332 -0
  5. package/dist/api/delete/deleteAllRules.d.ts +2 -2
  6. package/dist/api/delete/deleteAllRules.d.ts.map +1 -1
  7. package/dist/api/delete/deleteAllRules.js +3 -2
  8. package/dist/api/delete/deleteAllRules.js.map +1 -1
  9. package/dist/api/delete/deleteRuleGroups.d.ts +2 -2
  10. package/dist/api/delete/deleteRuleGroups.d.ts.map +1 -1
  11. package/dist/api/delete/deleteRuleGroups.js +3 -2
  12. package/dist/api/delete/deleteRuleGroups.js.map +1 -1
  13. package/dist/api/delete/deleteRules.d.ts +2 -2
  14. package/dist/api/delete/deleteRules.d.ts.map +1 -1
  15. package/dist/api/delete/deleteRules.js +3 -2
  16. package/dist/api/delete/deleteRules.js.map +1 -1
  17. package/dist/api/read/fetchRules.d.ts +2 -2
  18. package/dist/api/read/fetchRules.d.ts.map +1 -1
  19. package/dist/api/read/fetchRules.js +4 -3
  20. package/dist/api/read/fetchRules.js.map +1 -1
  21. package/dist/api/read/findRuleIds.d.ts +2 -2
  22. package/dist/api/read/findRuleIds.d.ts.map +1 -1
  23. package/dist/api/read/findRuleIds.js +3 -2
  24. package/dist/api/read/findRuleIds.js.map +1 -1
  25. package/dist/api/read/getMissingIds.d.ts +2 -2
  26. package/dist/api/read/getMissingIds.d.ts.map +1 -1
  27. package/dist/api/read/getMissingIds.js +4 -3
  28. package/dist/api/read/getMissingIds.js.map +1 -1
  29. package/dist/api/ruleApiRoutes.d.ts +1 -1
  30. package/dist/api/ruleApiRoutes.d.ts.map +1 -1
  31. package/dist/api/ruleApiRoutes.js.map +1 -1
  32. package/dist/api/rulesApiClient.d.ts +9 -9
  33. package/dist/api/rulesApiClient.d.ts.map +1 -1
  34. package/dist/api/rulesApiClient.js +18 -19
  35. package/dist/api/rulesApiClient.js.map +1 -1
  36. package/dist/api/write/insertRules.d.ts +2 -2
  37. package/dist/api/write/insertRules.d.ts.map +1 -1
  38. package/dist/api/write/insertRules.js +7 -6
  39. package/dist/api/write/insertRules.js.map +1 -1
  40. package/dist/api/write/rehashRules.d.ts +2 -2
  41. package/dist/api/write/rehashRules.d.ts.map +1 -1
  42. package/dist/api/write/rehashRules.js +7 -6
  43. package/dist/api/write/rehashRules.js.map +1 -1
  44. package/dist/cjs/api/delete/deleteAllRules.cjs +3 -2
  45. package/dist/cjs/api/delete/deleteRuleGroups.cjs +3 -2
  46. package/dist/cjs/api/delete/deleteRules.cjs +3 -2
  47. package/dist/cjs/api/read/fetchRules.cjs +4 -3
  48. package/dist/cjs/api/read/findRuleIds.cjs +3 -2
  49. package/dist/cjs/api/read/getMissingIds.cjs +4 -3
  50. package/dist/cjs/api/rulesApiClient.cjs +18 -19
  51. package/dist/cjs/api/write/insertRules.cjs +9 -8
  52. package/dist/cjs/api/write/rehashRules.cjs +7 -6
  53. package/dist/cjs/mongoose/mongooseRuleSchema.cjs +2 -1
  54. package/dist/cjs/redis/reader/redisRulesQuery.cjs +6 -0
  55. package/dist/cjs/redis/reader/redisRulesReader.cjs +13 -4
  56. package/dist/cjs/redis/redisRuleIndex.cjs +2 -1
  57. package/dist/cjs/ruleInput/userScopeInput.cjs +2 -1
  58. package/dist/cjs/ruleRecord.cjs +2 -1
  59. package/dist/mongoose/mongooseRuleSchema.d.ts.map +1 -1
  60. package/dist/mongoose/mongooseRuleSchema.js +2 -1
  61. package/dist/mongoose/mongooseRuleSchema.js.map +1 -1
  62. package/dist/redis/reader/redisAggregate.d.ts +1 -1
  63. package/dist/redis/reader/redisRulesQuery.d.ts.map +1 -1
  64. package/dist/redis/reader/redisRulesQuery.js +6 -0
  65. package/dist/redis/reader/redisRulesQuery.js.map +1 -1
  66. package/dist/redis/reader/redisRulesReader.d.ts +1 -1
  67. package/dist/redis/reader/redisRulesReader.d.ts.map +1 -1
  68. package/dist/redis/reader/redisRulesReader.js +14 -5
  69. package/dist/redis/reader/redisRulesReader.js.map +1 -1
  70. package/dist/redis/redisClient.d.ts +1 -1
  71. package/dist/redis/redisRuleIndex.d.ts.map +1 -1
  72. package/dist/redis/redisRuleIndex.js +2 -1
  73. package/dist/redis/redisRuleIndex.js.map +1 -1
  74. package/dist/redis/redisRulesStorage.d.ts +1 -1
  75. package/dist/redis/redisRulesWriter.d.ts +1 -1
  76. package/dist/redis/redisRulesWriter.d.ts.map +1 -1
  77. package/dist/redis/redisRulesWriter.js.map +1 -1
  78. package/dist/rule.d.ts +1 -0
  79. package/dist/rule.d.ts.map +1 -1
  80. package/dist/ruleInput/ruleInput.d.ts +6 -0
  81. package/dist/ruleInput/ruleInput.d.ts.map +1 -1
  82. package/dist/ruleInput/userScopeInput.d.ts +8 -0
  83. package/dist/ruleInput/userScopeInput.d.ts.map +1 -1
  84. package/dist/ruleInput/userScopeInput.js +2 -1
  85. package/dist/ruleInput/userScopeInput.js.map +1 -1
  86. package/dist/ruleRecord.d.ts +2 -2
  87. package/dist/ruleRecord.d.ts.map +1 -1
  88. package/dist/ruleRecord.js +2 -1
  89. package/dist/ruleRecord.js.map +1 -1
  90. package/dist/tests/insertRulesEndpoint.unit.test.d.ts +2 -0
  91. package/dist/tests/insertRulesEndpoint.unit.test.d.ts.map +1 -0
  92. package/dist/tests/insertRulesEndpoint.unit.test.js +57 -0
  93. package/dist/tests/insertRulesEndpoint.unit.test.js.map +1 -0
  94. package/dist/tests/redis/reader/redisRulesQuery.unit.test.js +42 -3
  95. package/dist/tests/redis/reader/redisRulesQuery.unit.test.js.map +1 -1
  96. package/dist/tests/redis/redisRulesStorage.integration.test.js +126 -1
  97. package/dist/tests/redis/redisRulesStorage.integration.test.js.map +1 -1
  98. package/dist/tests/testLogger.d.ts +1 -1
  99. package/dist/tests/transformRule.unit.test.js +1 -0
  100. package/dist/tests/transformRule.unit.test.js.map +1 -1
  101. package/package.json +15 -10
  102. package/src/.export.ts +44 -0
  103. package/src/api/.export.ts +25 -0
  104. package/src/api/accessRulesApiClient.ts +13 -0
  105. package/src/api/delete/.export.ts +18 -0
  106. package/src/api/delete/deleteAllRules.ts +47 -0
  107. package/src/api/delete/deleteRuleGroups.ts +96 -0
  108. package/src/api/delete/deleteRules.ts +81 -0
  109. package/src/api/read/.export.ts +25 -0
  110. package/src/api/read/fetchRules.ts +88 -0
  111. package/src/api/read/findRuleIds.ts +95 -0
  112. package/src/api/read/getMissingIds.ts +81 -0
  113. package/src/api/ruleApiRoutes.ts +146 -0
  114. package/src/api/rulesApiClient.ts +154 -0
  115. package/src/api/write/.export.ts +15 -0
  116. package/src/api/write/insertRules.ts +183 -0
  117. package/src/api/write/rehashRules.ts +85 -0
  118. package/src/mongoose/.export.ts +15 -0
  119. package/src/mongoose/mongooseRuleSchema.ts +65 -0
  120. package/src/redis/.export.ts +17 -0
  121. package/src/redis/reader/redisAggregate.ts +103 -0
  122. package/src/redis/reader/redisRulesQuery.ts +217 -0
  123. package/src/redis/reader/redisRulesReader.ts +318 -0
  124. package/src/redis/redisClient.ts +120 -0
  125. package/src/redis/redisRuleIndex.ts +85 -0
  126. package/src/redis/redisRulesStorage.ts +68 -0
  127. package/src/redis/redisRulesWriter.ts +158 -0
  128. package/src/rule.ts +59 -0
  129. package/src/ruleInput/.export.ts +19 -0
  130. package/src/ruleInput/policyInput.ts +51 -0
  131. package/src/ruleInput/ruleInput.ts +103 -0
  132. package/src/ruleInput/userScopeInput.ts +108 -0
  133. package/src/ruleRecord.ts +69 -0
  134. package/src/rulesStorage.ts +72 -0
  135. package/src/tests/insertRulesEndpoint.unit.test.ts +89 -0
  136. package/src/tests/policyInput.unit.test.ts +150 -0
  137. package/src/tests/redis/reader/redisRulesQuery.unit.test.ts +284 -0
  138. package/src/tests/redis/redisRulesStorage.integration.test.ts +1156 -0
  139. package/src/tests/testLogger.ts +38 -0
  140. package/src/tests/transformRule.unit.test.ts +255 -0
  141. package/src/transformRule.ts +128 -0
  142. package/tsconfig.cjs.json +41 -0
  143. package/tsconfig.json +47 -0
  144. package/tsconfig.tsbuildinfo +1 -0
  145. package/tsconfig.types.json +9 -0
@@ -0,0 +1,1156 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import { chunkIntoBatches, executeBatchesSequentially } from "@prosopo/common";
16
+ import { LogLevel, type Logger, getLogger } from "@prosopo/logger";
17
+ import {
18
+ type RedisConnection,
19
+ createTestRedisConnection,
20
+ setupRedisIndex,
21
+ } from "@prosopo/redis-client";
22
+ import { randomAsHex } from "@prosopo/util-crypto";
23
+ import type { RedisClientType } from "redis";
24
+ import {
25
+ afterAll,
26
+ afterEach,
27
+ beforeAll,
28
+ beforeEach,
29
+ describe,
30
+ expect,
31
+ test,
32
+ } from "vitest";
33
+ import { RedisRulesReader } from "#policy/redis/reader/redisRulesReader.js";
34
+ import {
35
+ ACCESS_RULE_REDIS_KEY_PREFIX,
36
+ accessRulesRedisIndex,
37
+ getAccessRuleRedisKey,
38
+ } from "#policy/redis/redisRuleIndex.js";
39
+ import {
40
+ RedisRulesWriter,
41
+ getRedisRuleValue,
42
+ } from "#policy/redis/redisRulesWriter.js";
43
+ import { AccessPolicyType, type AccessRule } from "#policy/rule.js";
44
+ import {
45
+ type AccessRulesReader,
46
+ type AccessRulesWriter,
47
+ FilterScopeMatch,
48
+ } from "#policy/rulesStorage.js";
49
+
50
+ describe("redisAccessRulesStorage", () => {
51
+ let redisConnection: RedisConnection;
52
+ let redisClient: RedisClientType;
53
+ let indexName: string;
54
+
55
+ const mockLogger = new Proxy(
56
+ {},
57
+ {
58
+ get: () => () => {},
59
+ },
60
+ ) as unknown as Logger;
61
+
62
+ const getUniqueString = () => Math.random().toString(36).substring(2, 15);
63
+ const getIndexRecordsCount = async (indexName: string): Promise<number> =>
64
+ (await redisClient.ft.info(indexName)).num_docs;
65
+
66
+ const insertRules = async (rules: AccessRule[]) => {
67
+ const ruleBatches = chunkIntoBatches(rules, 1000);
68
+
69
+ await executeBatchesSequentially(ruleBatches, async (batchRules) => {
70
+ const queries = redisClient.multi();
71
+
72
+ batchRules.map((rule) => {
73
+ const ruleKey = getAccessRuleRedisKey(rule);
74
+ const ruleValue = getRedisRuleValue(rule);
75
+
76
+ queries.hSet(ruleKey, ruleValue);
77
+ });
78
+
79
+ await queries.exec();
80
+ });
81
+ };
82
+
83
+ beforeAll(async () => {
84
+ redisConnection = createTestRedisConnection();
85
+
86
+ redisClient = await setupRedisIndex(
87
+ redisConnection,
88
+ accessRulesRedisIndex,
89
+ mockLogger,
90
+ ).getClient();
91
+ });
92
+
93
+ // Move cleanup to afterEach
94
+ afterEach(async () => {
95
+ if (indexName) {
96
+ try {
97
+ // Drop index and all documents (DD) created by THIS specific test
98
+ await redisClient.ft.dropIndex(indexName, { DD: true });
99
+ } catch (e) {
100
+ console.error(`Failed to cleanup index ${indexName}`, e);
101
+ }
102
+ }
103
+ }, 120_000);
104
+
105
+ beforeEach(async () => {
106
+ indexName = randomAsHex(16);
107
+
108
+ const result = setupRedisIndex(
109
+ redisConnection,
110
+ { ...accessRulesRedisIndex, name: indexName },
111
+ mockLogger,
112
+ );
113
+ redisClient = await result.getClient();
114
+ });
115
+
116
+ describe(
117
+ "writer",
118
+ () => {
119
+ let accessRulesWriter: AccessRulesWriter;
120
+
121
+ beforeAll(() => {
122
+ accessRulesWriter = new RedisRulesWriter(redisClient, mockLogger);
123
+ });
124
+
125
+ test("inserts rule", async () => {
126
+ const testIndexName = indexName;
127
+ // given
128
+ const accessRule: AccessRule = {
129
+ type: AccessPolicyType.Block,
130
+ clientId: "clientId",
131
+ };
132
+ const accessRuleKey = getAccessRuleRedisKey(accessRule);
133
+
134
+ // when
135
+ await accessRulesWriter.insertRules([
136
+ {
137
+ rule: accessRule,
138
+ },
139
+ ]);
140
+
141
+ // then
142
+ const insertedAccessRule = await redisClient.hGetAll(accessRuleKey);
143
+ const indexRecordsCount = await getIndexRecordsCount(testIndexName);
144
+
145
+ expect(insertedAccessRule).toEqual(accessRule);
146
+ expect(indexRecordsCount).toEqual(1);
147
+ });
148
+
149
+ test("inserts time limited rule", async () => {
150
+ // given
151
+ const accessRule: AccessRule = {
152
+ type: AccessPolicyType.Block,
153
+ clientId: "clientId",
154
+ };
155
+ const accessRuleKey = getAccessRuleRedisKey(accessRule);
156
+ // 1 hour from now.
157
+ const expireIn = 60 * 60; // seconds
158
+ const expirationTimestamp = new Date(
159
+ Date.now() + expireIn * 1000,
160
+ ).getTime();
161
+ const expirationTimestampInSeconds = Math.floor(
162
+ expirationTimestamp / 1000,
163
+ );
164
+
165
+ // when
166
+ await accessRulesWriter.insertRules([
167
+ {
168
+ rule: accessRule,
169
+ expiresUnixTimestamp: expirationTimestampInSeconds,
170
+ },
171
+ ]);
172
+ const ruleKey = getAccessRuleRedisKey(accessRule);
173
+ // then
174
+ const insertedAccessRule = await redisClient.hGetAll(accessRuleKey);
175
+ const insertedExpirationResult = await redisClient.expireAt(
176
+ ruleKey,
177
+ expirationTimestampInSeconds,
178
+ );
179
+ const indexRecordsCount = await getIndexRecordsCount(indexName);
180
+
181
+ const recordExpirySeconds = await redisClient.ttl(ruleKey);
182
+
183
+ expect(insertedAccessRule).toEqual(accessRule);
184
+ expect(insertedExpirationResult).toBe(1);
185
+ expect(recordExpirySeconds).toBeLessThanOrEqual(
186
+ expirationTimestampInSeconds,
187
+ );
188
+
189
+ expect(indexRecordsCount).toBe(1);
190
+ });
191
+
192
+ test("deletes rules", async () => {
193
+ // given
194
+ const johnAccessRule: AccessRule = {
195
+ type: AccessPolicyType.Block,
196
+ clientId: getUniqueString(),
197
+ };
198
+ const johnAccessRuleKey = getAccessRuleRedisKey(johnAccessRule);
199
+
200
+ const doeAccessRule: AccessRule = {
201
+ type: AccessPolicyType.Block,
202
+ clientId: getUniqueString(),
203
+ };
204
+ const doeAccessRuleKey = getAccessRuleRedisKey(doeAccessRule);
205
+
206
+ await insertRules([johnAccessRule, doeAccessRule]);
207
+
208
+ // when
209
+ await accessRulesWriter.deleteRules([
210
+ johnAccessRuleKey.slice(ACCESS_RULE_REDIS_KEY_PREFIX.length),
211
+ ]);
212
+
213
+ // then
214
+ const presentAccessRule = await redisClient.hGetAll(doeAccessRuleKey);
215
+ const indexRecordsCount = await getIndexRecordsCount(indexName);
216
+
217
+ expect(presentAccessRule).toEqual(doeAccessRule);
218
+ expect(indexRecordsCount).toBe(1);
219
+ });
220
+
221
+ test("deletes all rules", async () => {
222
+ // given
223
+ const johnAccessRule: AccessRule = {
224
+ type: AccessPolicyType.Block,
225
+ clientId: getUniqueString(),
226
+ };
227
+ const doeAccessRule: AccessRule = {
228
+ type: AccessPolicyType.Block,
229
+ clientId: getUniqueString(),
230
+ };
231
+
232
+ await insertRules([johnAccessRule, doeAccessRule]);
233
+
234
+ // when
235
+ await accessRulesWriter.deleteAllRules();
236
+
237
+ // then
238
+ const indexRecordsCount = await getIndexRecordsCount(indexName);
239
+
240
+ expect(indexRecordsCount).toBe(0);
241
+ });
242
+
243
+ test("deletes all rules when there are 1 million rules", async () => {
244
+ // given
245
+ const rulesCount = 1_000_000;
246
+ const batchSize = 10_000;
247
+ const numBatches = Math.ceil(rulesCount / batchSize);
248
+
249
+ // Insert rules in batches to avoid memory exhaustion
250
+ // Don't create 1M objects in memory at once!
251
+ for (let i = 0; i < numBatches; i++) {
252
+ const currentBatchSize = Math.min(
253
+ batchSize,
254
+ rulesCount - i * batchSize,
255
+ );
256
+ const batchRules: AccessRule[] = Array.from(
257
+ { length: currentBatchSize },
258
+ () => ({
259
+ type: AccessPolicyType.Block,
260
+ clientId: getUniqueString(),
261
+ }),
262
+ );
263
+
264
+ await insertRules(batchRules);
265
+ }
266
+
267
+ // verify that there are 1 million rules in the database
268
+ const beforeDeleteIndexRecordsCount =
269
+ await getIndexRecordsCount(indexName);
270
+ expect(beforeDeleteIndexRecordsCount).toBe(rulesCount);
271
+
272
+ // when
273
+ await accessRulesWriter.deleteAllRules();
274
+
275
+ // then
276
+ const afterDeleteIndexRecordsCount =
277
+ await getIndexRecordsCount(indexName);
278
+
279
+ expect(afterDeleteIndexRecordsCount).toBe(0);
280
+ });
281
+ },
282
+ {
283
+ timeout: 240_000,
284
+ },
285
+ );
286
+
287
+ describe("reader", () => {
288
+ let accessRulesReader: AccessRulesReader;
289
+
290
+ beforeAll(async () => {
291
+ accessRulesReader = new RedisRulesReader(
292
+ redisClient,
293
+ getLogger(LogLevel.enum.info, "RedisAccessRulesReader"),
294
+ );
295
+ });
296
+
297
+ test("finds client and global rules by greedy policy scope match", async () => {
298
+ // given
299
+ const johnId = getUniqueString();
300
+ const johnAccessRule: AccessRule = {
301
+ type: AccessPolicyType.Block,
302
+ clientId: johnId,
303
+ };
304
+ const doeAccessRule: AccessRule = {
305
+ type: AccessPolicyType.Block,
306
+ clientId: getUniqueString(),
307
+ };
308
+ const globalAccessRule: AccessRule = {
309
+ type: AccessPolicyType.Block,
310
+ };
311
+
312
+ await insertRules([johnAccessRule, doeAccessRule, globalAccessRule]);
313
+
314
+ // when
315
+ const foundByClientAccessRules = await accessRulesReader.findRules({
316
+ policyScope: {
317
+ clientId: johnId,
318
+ },
319
+ policyScopeMatch: FilterScopeMatch.Greedy,
320
+ });
321
+ const foundAccessRules = await accessRulesReader.findRules({
322
+ policyScopeMatch: FilterScopeMatch.Greedy,
323
+ });
324
+
325
+ // then
326
+ const indexRecordsCount = await getIndexRecordsCount(indexName);
327
+
328
+ expect(indexRecordsCount).toBe(3);
329
+ expect(foundByClientAccessRules).toEqual([
330
+ johnAccessRule,
331
+ globalAccessRule,
332
+ ]);
333
+ expect(foundAccessRules).toEqual([
334
+ johnAccessRule,
335
+ doeAccessRule,
336
+ globalAccessRule,
337
+ ]);
338
+ });
339
+
340
+ test("finds client or global rules by exact policy scope match", async () => {
341
+ // given
342
+ const johnId = getUniqueString();
343
+
344
+ const johnAccessRule: AccessRule = {
345
+ type: AccessPolicyType.Block,
346
+ clientId: johnId,
347
+ };
348
+ const doeAccessRule: AccessRule = {
349
+ type: AccessPolicyType.Block,
350
+ clientId: getUniqueString(),
351
+ };
352
+ const globalAccessRule: AccessRule = {
353
+ type: AccessPolicyType.Block,
354
+ };
355
+
356
+ await insertRules([johnAccessRule, doeAccessRule, globalAccessRule]);
357
+
358
+ // when
359
+ const foundClientAccessRules = await accessRulesReader.findRules({
360
+ policyScope: {
361
+ clientId: johnId,
362
+ },
363
+ policyScopeMatch: FilterScopeMatch.Exact,
364
+ });
365
+ const foundGlobalAccessRules = await accessRulesReader.findRules(
366
+ {
367
+ policyScopeMatch: FilterScopeMatch.Exact,
368
+ },
369
+ false,
370
+ false,
371
+ );
372
+
373
+ // then
374
+ const indexRecordsCount = await getIndexRecordsCount(indexName);
375
+
376
+ expect(indexRecordsCount).toBe(3);
377
+ expect(foundClientAccessRules).toEqual([johnAccessRule]);
378
+ expect(foundGlobalAccessRules).toEqual([globalAccessRule]);
379
+ });
380
+
381
+ test("finds rules by greedy user scope match", async () => {
382
+ // given
383
+
384
+ const johnId = getUniqueString();
385
+ const johnIpAccessRule: AccessRule = {
386
+ type: AccessPolicyType.Block,
387
+ clientId: johnId,
388
+ numericIp: BigInt(100),
389
+ };
390
+ const doeIpAccessRule: AccessRule = {
391
+ type: AccessPolicyType.Block,
392
+ clientId: getUniqueString(),
393
+ numericIp: BigInt(100),
394
+ };
395
+ const johnHeaderAccessRule: AccessRule = {
396
+ type: AccessPolicyType.Block,
397
+ headersHash:
398
+ "00110110100001111101001101100101101101001000011111010011011001010101000110000111110100110110010111010100100011011101101010000011",
399
+ };
400
+ const globalJa4AccessRule: AccessRule = {
401
+ type: AccessPolicyType.Block,
402
+ ja4Hash: "windows",
403
+ };
404
+
405
+ await insertRules([
406
+ johnIpAccessRule,
407
+ doeIpAccessRule,
408
+ johnHeaderAccessRule,
409
+ globalJa4AccessRule,
410
+ ]);
411
+
412
+ // when
413
+ const foundAccessRules = await accessRulesReader.findRules({
414
+ policyScope: {
415
+ clientId: johnId,
416
+ },
417
+ userScope: {
418
+ numericIp: BigInt(100),
419
+ ja4Hash: "windows",
420
+ },
421
+ userScopeMatch: FilterScopeMatch.Greedy,
422
+ });
423
+
424
+ // then
425
+ const indexRecordsCount = await getIndexRecordsCount(indexName);
426
+
427
+ expect(indexRecordsCount).toBe(4);
428
+ expect(foundAccessRules).toEqual([johnIpAccessRule, globalJa4AccessRule]);
429
+ });
430
+
431
+ test("finds rules by exact user scope match", async () => {
432
+ // given
433
+
434
+ const johnId = getUniqueString();
435
+
436
+ const johnTargetAccessRule: AccessRule = {
437
+ type: AccessPolicyType.Block,
438
+ clientId: johnId,
439
+ numericIp: BigInt(100),
440
+ ja4Hash: "windows",
441
+ };
442
+
443
+ const doeTargetAccessRule: AccessRule = {
444
+ type: AccessPolicyType.Block,
445
+ clientId: getUniqueString(),
446
+ numericIp: BigInt(100),
447
+ ja4Hash: "windows",
448
+ };
449
+
450
+ const johnHeaderAccessRule: AccessRule = {
451
+ type: AccessPolicyType.Block,
452
+ headersHash:
453
+ "00110110100001111101001101100101101101001000011111010011011001010101000110000111110100110110010111010100100011011101101010000011",
454
+ };
455
+
456
+ const globalTargetAccessRule: AccessRule = {
457
+ type: AccessPolicyType.Block,
458
+ numericIp: BigInt(100),
459
+ ja4Hash: "windows",
460
+ };
461
+
462
+ const globalJa4AccessRule: AccessRule = {
463
+ type: AccessPolicyType.Block,
464
+ ja4Hash: "windows",
465
+ };
466
+
467
+ await insertRules([
468
+ johnTargetAccessRule,
469
+ doeTargetAccessRule,
470
+ johnHeaderAccessRule,
471
+ globalTargetAccessRule,
472
+ globalJa4AccessRule,
473
+ ]);
474
+
475
+ // when
476
+ const foundAccessRules = await accessRulesReader.findRules({
477
+ policyScope: {
478
+ clientId: johnId,
479
+ },
480
+ userScope: {
481
+ numericIp: BigInt(100),
482
+ ja4Hash: "windows",
483
+ },
484
+ userScopeMatch: FilterScopeMatch.Exact,
485
+ });
486
+
487
+ // then
488
+ const indexRecordsCount = await getIndexRecordsCount(indexName);
489
+
490
+ expect(indexRecordsCount).toBe(5);
491
+ expect(foundAccessRules).toEqual([
492
+ johnTargetAccessRule,
493
+ globalTargetAccessRule,
494
+ ]);
495
+ });
496
+
497
+ test("finds rules by greedy ip match", async () => {
498
+ // given
499
+ const johnId = getUniqueString();
500
+
501
+ const johnIpMask_0_100_AccessRule: AccessRule = {
502
+ clientId: johnId,
503
+ type: AccessPolicyType.Block,
504
+ numericIpMaskMin: BigInt(0),
505
+ numericIpMaskMax: BigInt(100),
506
+ };
507
+ const johnIp_100_AccessRule: AccessRule = {
508
+ clientId: johnId,
509
+ type: AccessPolicyType.Block,
510
+ numericIp: BigInt(100),
511
+ };
512
+ const globalIpMask_100_200_AccessRule: AccessRule = {
513
+ type: AccessPolicyType.Block,
514
+ numericIpMaskMin: BigInt(100),
515
+ numericIpMaskMax: BigInt(200),
516
+ };
517
+ const doeIpMask_200_300AccessRule: AccessRule = {
518
+ clientId: getUniqueString(),
519
+ type: AccessPolicyType.Block,
520
+ numericIpMaskMin: BigInt(200),
521
+ numericIpMaskMax: BigInt(300),
522
+ };
523
+
524
+ await insertRules([
525
+ johnIpMask_0_100_AccessRule,
526
+ johnIp_100_AccessRule,
527
+ globalIpMask_100_200_AccessRule,
528
+ doeIpMask_200_300AccessRule,
529
+ ]);
530
+
531
+ // when
532
+ const ip_0_accessRules = await accessRulesReader.findRules({
533
+ policyScope: {
534
+ clientId: johnId,
535
+ },
536
+ userScope: {
537
+ numericIp: BigInt(0),
538
+ },
539
+ userScopeMatch: FilterScopeMatch.Greedy,
540
+ });
541
+ const ip_99_accessRules = await accessRulesReader.findRules({
542
+ policyScope: {
543
+ clientId: johnId,
544
+ },
545
+ userScope: {
546
+ numericIp: BigInt(99),
547
+ },
548
+ userScopeMatch: FilterScopeMatch.Greedy,
549
+ });
550
+ const ip_100_accessRules = await accessRulesReader.findRules({
551
+ policyScope: {
552
+ clientId: johnId,
553
+ },
554
+ userScope: {
555
+ numericIp: BigInt(100),
556
+ },
557
+ userScopeMatch: FilterScopeMatch.Greedy,
558
+ });
559
+ const ip_101_accessRules = await accessRulesReader.findRules({
560
+ policyScope: {
561
+ clientId: johnId,
562
+ },
563
+ userScope: {
564
+ numericIp: BigInt(101),
565
+ },
566
+ userScopeMatch: FilterScopeMatch.Greedy,
567
+ });
568
+ const ip_201_accessRules = await accessRulesReader.findRules({
569
+ policyScope: {
570
+ clientId: johnId,
571
+ },
572
+ userScope: {
573
+ numericIp: BigInt(201),
574
+ },
575
+ userScopeMatch: FilterScopeMatch.Greedy,
576
+ });
577
+
578
+ // then
579
+ const indexRecordsCount = await getIndexRecordsCount(indexName);
580
+
581
+ expect(indexRecordsCount).toBe(4);
582
+
583
+ expect(ip_0_accessRules).toEqual([johnIpMask_0_100_AccessRule]);
584
+ expect(ip_99_accessRules).toEqual([johnIpMask_0_100_AccessRule]);
585
+ expect(ip_100_accessRules).toEqual([
586
+ johnIpMask_0_100_AccessRule,
587
+ johnIp_100_AccessRule,
588
+ globalIpMask_100_200_AccessRule,
589
+ ]);
590
+ expect(ip_101_accessRules).toEqual([globalIpMask_100_200_AccessRule]);
591
+ expect(ip_201_accessRules).toEqual([]);
592
+ });
593
+
594
+ test("finds rules by exact ip match with exact policy match", async () => {
595
+ // given
596
+ const johnId = getUniqueString();
597
+
598
+ const johnIpMask_0_100_AccessRule: AccessRule = {
599
+ clientId: johnId,
600
+ type: AccessPolicyType.Block,
601
+ numericIpMaskMin: BigInt(0),
602
+ numericIpMaskMax: BigInt(100),
603
+ };
604
+ const johnIp_100_AccessRule: AccessRule = {
605
+ clientId: johnId,
606
+ type: AccessPolicyType.Block,
607
+ numericIp: BigInt(100),
608
+ };
609
+ const globalIpMask_100_200_AccessRule: AccessRule = {
610
+ type: AccessPolicyType.Block,
611
+ numericIpMaskMin: BigInt(100),
612
+ numericIpMaskMax: BigInt(200),
613
+ };
614
+ const globalIp_100_AccessRule: AccessRule = {
615
+ type: AccessPolicyType.Block,
616
+ numericIp: BigInt(100),
617
+ };
618
+
619
+ await insertRules([
620
+ johnIpMask_0_100_AccessRule,
621
+ johnIp_100_AccessRule,
622
+ globalIpMask_100_200_AccessRule,
623
+ globalIp_100_AccessRule,
624
+ ]);
625
+
626
+ // when
627
+ const ip_0_accessRules = await accessRulesReader.findRules({
628
+ policyScope: {
629
+ clientId: johnId,
630
+ },
631
+ policyScopeMatch: FilterScopeMatch.Exact,
632
+ userScope: {
633
+ numericIp: BigInt(0),
634
+ },
635
+ userScopeMatch: FilterScopeMatch.Exact,
636
+ });
637
+ const ip_100_accessRules = await accessRulesReader.findRules({
638
+ policyScope: {
639
+ clientId: johnId,
640
+ },
641
+ policyScopeMatch: FilterScopeMatch.Exact,
642
+ userScope: {
643
+ numericIp: BigInt(100),
644
+ },
645
+ userScopeMatch: FilterScopeMatch.Exact,
646
+ });
647
+
648
+ // then
649
+ const indexRecordsCount = await getIndexRecordsCount(indexName);
650
+
651
+ expect(indexRecordsCount).toBe(4);
652
+
653
+ expect(ip_0_accessRules).toEqual([johnIpMask_0_100_AccessRule]);
654
+ expect(ip_100_accessRules).toEqual([
655
+ johnIpMask_0_100_AccessRule,
656
+ johnIp_100_AccessRule,
657
+ ]);
658
+ });
659
+
660
+ test("finds rules by exact ip match with exact policy match 2", async () => {
661
+ // given
662
+ const johnId = getUniqueString();
663
+ const johnIp = BigInt(100);
664
+
665
+ const johnIpMask_0_100_AccessRule: AccessRule = {
666
+ clientId: johnId,
667
+ type: AccessPolicyType.Block,
668
+ numericIpMaskMin: BigInt(0),
669
+ numericIpMaskMax: johnIp,
670
+ };
671
+ const johnIp_100_AccessRule: AccessRule = {
672
+ clientId: johnId,
673
+ type: AccessPolicyType.Block,
674
+ numericIp: johnIp,
675
+ };
676
+ const globalIpMask_100_200_AccessRule: AccessRule = {
677
+ type: AccessPolicyType.Block,
678
+ numericIpMaskMin: johnIp,
679
+ numericIpMaskMax: BigInt(200),
680
+ };
681
+ const globalIp_100_AccessRule: AccessRule = {
682
+ type: AccessPolicyType.Block,
683
+ numericIp: johnIp,
684
+ };
685
+
686
+ await insertRules([
687
+ johnIpMask_0_100_AccessRule,
688
+ johnIp_100_AccessRule,
689
+ globalIpMask_100_200_AccessRule,
690
+ globalIp_100_AccessRule,
691
+ ]);
692
+
693
+ // when
694
+ const ip_0_accessRules = await accessRulesReader.findRules({
695
+ policyScope: {
696
+ clientId: johnId,
697
+ },
698
+ policyScopeMatch: FilterScopeMatch.Exact,
699
+ userScope: {
700
+ numericIp: BigInt(0),
701
+ },
702
+ userScopeMatch: FilterScopeMatch.Exact,
703
+ });
704
+ const ip_100_accessRules = await accessRulesReader.findRules({
705
+ policyScopeMatch: FilterScopeMatch.Exact,
706
+ userScope: {
707
+ numericIp: johnIp,
708
+ },
709
+ userScopeMatch: FilterScopeMatch.Exact,
710
+ });
711
+
712
+ // then
713
+ const indexRecordsCount = await getIndexRecordsCount(indexName);
714
+
715
+ expect(indexRecordsCount).toBe(4);
716
+
717
+ expect(ip_0_accessRules).toEqual([johnIpMask_0_100_AccessRule]);
718
+ expect(ip_100_accessRules).toEqual([
719
+ globalIpMask_100_200_AccessRule,
720
+ globalIp_100_AccessRule,
721
+ ]);
722
+ });
723
+
724
+ test("does not find rules when some criteria do not match and user scope match is exact", async () => {
725
+ // given
726
+ const accessRule: AccessRule = {
727
+ type: AccessPolicyType.Restrict,
728
+ clientId: "clientId",
729
+ userAgentHash: "userAgentHash1",
730
+ ja4Hash: "ja4Hash",
731
+ };
732
+
733
+ // when
734
+ await insertRules([accessRule]);
735
+
736
+ // then
737
+ const query = {
738
+ policyScope: {
739
+ clientId: "clientId",
740
+ },
741
+ policyScopeMatch: FilterScopeMatch.Exact,
742
+ userScope: {
743
+ ja4Hash: "ja4Hash",
744
+ userAgentHash: "userAgentHash2",
745
+ },
746
+ userScopeMatch: FilterScopeMatch.Exact,
747
+ };
748
+
749
+ const foundAccessRules = await accessRulesReader.findRules(query);
750
+ expect(foundAccessRules).toEqual([]);
751
+ });
752
+
753
+ test("does not find rules when query is more exact than rule and user scope match is exact", async () => {
754
+ // given
755
+ const accessRule: AccessRule = {
756
+ type: AccessPolicyType.Restrict,
757
+ clientId: "clientId",
758
+ ja4Hash: "ja4Hash",
759
+ };
760
+
761
+ // when
762
+ await insertRules([accessRule]);
763
+
764
+ // then
765
+ const query = {
766
+ policyScope: {
767
+ clientId: "clientId",
768
+ },
769
+ policyScopeMatch: FilterScopeMatch.Exact,
770
+ userScope: {
771
+ ja4Hash: "ja4Hash",
772
+ userAgentHash: "userAgentHash",
773
+ },
774
+ userScopeMatch: FilterScopeMatch.Exact,
775
+ };
776
+
777
+ const foundAccessRules = await accessRulesReader.findRules(query);
778
+ expect(foundAccessRules).toEqual([]);
779
+ });
780
+
781
+ test("does find rules when query is more exact than rule and user scope match is greedy", async () => {
782
+ // given
783
+ const accessRule: AccessRule = {
784
+ type: AccessPolicyType.Restrict,
785
+ clientId: "clientId",
786
+ ja4Hash: "ja4Hash",
787
+ };
788
+
789
+ // when
790
+ await insertRules([accessRule]);
791
+
792
+ // then
793
+ const query = {
794
+ policyScope: {
795
+ clientId: "clientId",
796
+ },
797
+ policyScopeMatch: FilterScopeMatch.Exact,
798
+ userScope: {
799
+ ja4Hash: "ja4Hash",
800
+ userAgentHash: "userAgentHash",
801
+ },
802
+ userScopeMatch: FilterScopeMatch.Greedy,
803
+ };
804
+
805
+ const foundAccessRules = await accessRulesReader.findRules(query);
806
+ expect(foundAccessRules).toEqual([accessRule]);
807
+ });
808
+
809
+ test("does find rules when some criteria do not match and user scope match is exact", async () => {
810
+ // given
811
+ const accessRule: AccessRule = {
812
+ type: AccessPolicyType.Restrict,
813
+ clientId: "clientId",
814
+ userAgentHash: "userAgentHash",
815
+ ja4Hash: "ja4Hash",
816
+ };
817
+
818
+ // when
819
+ getAccessRuleRedisKey(accessRule);
820
+ await insertRules([accessRule]);
821
+
822
+ // then
823
+ const query = {
824
+ policyScope: {
825
+ clientId: "clientId",
826
+ },
827
+ policyScopeMatch: FilterScopeMatch.Exact,
828
+ userScope: {
829
+ ja4Hash: "ja4Hash",
830
+ userAgentHash: "userAgentHash",
831
+ },
832
+ userScopeMatch: FilterScopeMatch.Greedy,
833
+ };
834
+
835
+ const foundAccessRules = await accessRulesReader.findRules(query);
836
+ expect(foundAccessRules).toEqual([accessRule]);
837
+ });
838
+
839
+ test("if an ip rule and an ip mask rule is applied for a single IP at client level, both rules are returned with the more specific IP rule being first in the list", async () => {
840
+ // given
841
+ const accessRule: AccessRule = {
842
+ type: AccessPolicyType.Restrict,
843
+ clientId: "clientId",
844
+ numericIp: BigInt(10),
845
+ };
846
+ const ipRangeAccessRule: AccessRule = {
847
+ type: AccessPolicyType.Restrict,
848
+ clientId: "clientId",
849
+ numericIpMaskMin: BigInt(10),
850
+ numericIpMaskMax: BigInt(20),
851
+ };
852
+
853
+ // when
854
+ await insertRules([accessRule, ipRangeAccessRule]);
855
+
856
+ // then
857
+
858
+ const query = {
859
+ policyScope: {
860
+ clientId: "clientId",
861
+ },
862
+ policyScopeMatch: FilterScopeMatch.Exact,
863
+ userScope: {
864
+ numericIp: BigInt(10),
865
+ },
866
+ userScopeMatch: FilterScopeMatch.Exact,
867
+ };
868
+
869
+ const foundAccessRules = await accessRulesReader.findRules(query);
870
+ expect(foundAccessRules).toEqual([accessRule, ipRangeAccessRule]);
871
+ });
872
+
873
+ test("finds rules by headHash with exact match", async () => {
874
+ // given
875
+ const headHash1 = "abc123def456";
876
+ const headHash2 = "xyz789ghi012";
877
+
878
+ const headHashAccessRule: AccessRule = {
879
+ type: AccessPolicyType.Block,
880
+ clientId: "clientId",
881
+ headHash: headHash1,
882
+ };
883
+ const otherHeadHashAccessRule: AccessRule = {
884
+ type: AccessPolicyType.Block,
885
+ clientId: "clientId",
886
+ headHash: headHash2,
887
+ };
888
+
889
+ await insertRules([headHashAccessRule, otherHeadHashAccessRule]);
890
+
891
+ // when
892
+ const foundAccessRules = await accessRulesReader.findRules({
893
+ policyScope: {
894
+ clientId: "clientId",
895
+ },
896
+ policyScopeMatch: FilterScopeMatch.Exact,
897
+ userScope: {
898
+ headHash: headHash1,
899
+ },
900
+ userScopeMatch: FilterScopeMatch.Exact,
901
+ });
902
+
903
+ // then
904
+ expect(foundAccessRules).toEqual([headHashAccessRule]);
905
+ });
906
+
907
+ test("finds rules by coords with exact match", async () => {
908
+ // given
909
+ const coords1 = "[[[100,200]]]";
910
+ const coords2 = "[[[300,400]]]";
911
+
912
+ const coordsAccessRule: AccessRule = {
913
+ type: AccessPolicyType.Block,
914
+ clientId: "clientId",
915
+ coords: coords1,
916
+ };
917
+ const otherCoordsAccessRule: AccessRule = {
918
+ type: AccessPolicyType.Block,
919
+ clientId: "clientId",
920
+ coords: coords2,
921
+ };
922
+
923
+ await insertRules([coordsAccessRule, otherCoordsAccessRule]);
924
+
925
+ // when
926
+ const foundAccessRules = await accessRulesReader.findRules({
927
+ policyScope: {
928
+ clientId: "clientId",
929
+ },
930
+ policyScopeMatch: FilterScopeMatch.Exact,
931
+ userScope: {
932
+ coords: coords1,
933
+ },
934
+ userScopeMatch: FilterScopeMatch.Exact,
935
+ });
936
+
937
+ // then
938
+ expect(foundAccessRules).toEqual([coordsAccessRule]);
939
+ });
940
+
941
+ test("finds rules by combined headHash and coords with exact match", async () => {
942
+ // given
943
+ const headHash1 = "abc123def456";
944
+ const coords1 = "[[[100,200]]]";
945
+
946
+ const combinedAccessRule: AccessRule = {
947
+ type: AccessPolicyType.Block,
948
+ clientId: "clientId",
949
+ headHash: headHash1,
950
+ coords: coords1,
951
+ };
952
+ const headHashOnlyAccessRule: AccessRule = {
953
+ type: AccessPolicyType.Restrict,
954
+ clientId: "clientId",
955
+ headHash: headHash1,
956
+ };
957
+
958
+ await insertRules([combinedAccessRule, headHashOnlyAccessRule]);
959
+
960
+ // when
961
+ const foundAccessRules = await accessRulesReader.findRules({
962
+ policyScope: {
963
+ clientId: "clientId",
964
+ },
965
+ policyScopeMatch: FilterScopeMatch.Exact,
966
+ userScope: {
967
+ headHash: headHash1,
968
+ coords: coords1,
969
+ },
970
+ userScopeMatch: FilterScopeMatch.Exact,
971
+ });
972
+
973
+ // then
974
+ expect(foundAccessRules).toEqual([combinedAccessRule]);
975
+ });
976
+
977
+ test("finds rules by countryCode with exact match", async () => {
978
+ // given
979
+ const countryCode1 = "US";
980
+ const countryCode2 = "GB";
981
+
982
+ const countryCodeAccessRule: AccessRule = {
983
+ type: AccessPolicyType.Block,
984
+ clientId: "clientId",
985
+ countryCode: countryCode1,
986
+ };
987
+ const otherCountryCodeAccessRule: AccessRule = {
988
+ type: AccessPolicyType.Block,
989
+ clientId: "clientId",
990
+ countryCode: countryCode2,
991
+ };
992
+
993
+ await insertRules([countryCodeAccessRule, otherCountryCodeAccessRule]);
994
+
995
+ // when
996
+ const foundAccessRules = await accessRulesReader.findRules({
997
+ policyScope: {
998
+ clientId: "clientId",
999
+ },
1000
+ policyScopeMatch: FilterScopeMatch.Exact,
1001
+ userScope: {
1002
+ countryCode: countryCode1,
1003
+ },
1004
+ userScopeMatch: FilterScopeMatch.Exact,
1005
+ });
1006
+
1007
+ // then
1008
+ expect(foundAccessRules).toEqual([countryCodeAccessRule]);
1009
+ });
1010
+
1011
+ test("finds rules by combined countryCode and userId with exact match", async () => {
1012
+ // given
1013
+ const countryCode1 = "US";
1014
+ const userId1 = "user123";
1015
+
1016
+ const combinedAccessRule: AccessRule = {
1017
+ type: AccessPolicyType.Block,
1018
+ clientId: "clientId",
1019
+ countryCode: countryCode1,
1020
+ userId: userId1,
1021
+ };
1022
+ const countryCodeOnlyAccessRule: AccessRule = {
1023
+ type: AccessPolicyType.Restrict,
1024
+ clientId: "clientId",
1025
+ countryCode: countryCode1,
1026
+ };
1027
+
1028
+ await insertRules([combinedAccessRule, countryCodeOnlyAccessRule]);
1029
+
1030
+ // when
1031
+ const foundAccessRules = await accessRulesReader.findRules({
1032
+ policyScope: {
1033
+ clientId: "clientId",
1034
+ },
1035
+ policyScopeMatch: FilterScopeMatch.Exact,
1036
+ userScope: {
1037
+ countryCode: countryCode1,
1038
+ userId: userId1,
1039
+ },
1040
+ userScopeMatch: FilterScopeMatch.Exact,
1041
+ });
1042
+
1043
+ // then
1044
+ expect(foundAccessRules).toEqual([combinedAccessRule]);
1045
+ });
1046
+
1047
+ test("returns remaining rules when a matched document's hash key has been deleted", async () => {
1048
+ // given
1049
+ const clientId = getUniqueString();
1050
+
1051
+ const survivingRule: AccessRule = {
1052
+ type: AccessPolicyType.Block,
1053
+ clientId: clientId,
1054
+ userId: "user1",
1055
+ };
1056
+ const deletedRule: AccessRule = {
1057
+ type: AccessPolicyType.Block,
1058
+ clientId: clientId,
1059
+ userId: "user2",
1060
+ };
1061
+
1062
+ await insertRules([survivingRule, deletedRule]);
1063
+
1064
+ // Delete the hash key for deletedRule directly, bypassing the writer.
1065
+ // The index may still reference it briefly, simulating the race
1066
+ // condition that caused the @redis/search documentValue(null) crash.
1067
+ const deletedRuleKey = getAccessRuleRedisKey(deletedRule);
1068
+ await redisClient.del(deletedRuleKey);
1069
+
1070
+ // when
1071
+ const foundAccessRules = await accessRulesReader.findRules({
1072
+ policyScope: {
1073
+ clientId: clientId,
1074
+ },
1075
+ policyScopeMatch: FilterScopeMatch.Exact,
1076
+ userScope: {
1077
+ userId: "user1",
1078
+ },
1079
+ userScopeMatch: FilterScopeMatch.Greedy,
1080
+ });
1081
+
1082
+ // then - should not throw and should return the surviving rule
1083
+ expect(foundAccessRules).toEqual([survivingRule]);
1084
+ });
1085
+
1086
+ test("returns empty array without throwing when all matched documents have been deleted", async () => {
1087
+ // given
1088
+ const clientId = getUniqueString();
1089
+
1090
+ const rule: AccessRule = {
1091
+ type: AccessPolicyType.Block,
1092
+ clientId: clientId,
1093
+ userId: "user1",
1094
+ };
1095
+
1096
+ await insertRules([rule]);
1097
+
1098
+ // Delete the hash key directly
1099
+ const ruleKey = getAccessRuleRedisKey(rule);
1100
+ await redisClient.del(ruleKey);
1101
+
1102
+ // when
1103
+ const foundAccessRules = await accessRulesReader.findRules({
1104
+ policyScope: {
1105
+ clientId: clientId,
1106
+ },
1107
+ policyScopeMatch: FilterScopeMatch.Exact,
1108
+ userScope: {
1109
+ userId: "user1",
1110
+ },
1111
+ userScopeMatch: FilterScopeMatch.Greedy,
1112
+ });
1113
+
1114
+ // then - should not throw, should return empty array
1115
+ expect(foundAccessRules).toEqual([]);
1116
+ });
1117
+
1118
+ test("finds rules with matchingFieldsOnly when only userId is set and all IP fields are missing", async () => {
1119
+ // This is the exact scenario from the production error where the query
1120
+ // contained duplicate ismissing(@numericIpMaskMin) ismissing(@numericIpMaskMax)
1121
+ // given
1122
+ const clientId = getUniqueString();
1123
+ const userId = getUniqueString();
1124
+
1125
+ const accessRule: AccessRule = {
1126
+ type: AccessPolicyType.Block,
1127
+ clientId: clientId,
1128
+ userId: userId,
1129
+ };
1130
+
1131
+ await insertRules([accessRule]);
1132
+
1133
+ // when - matchingFieldsOnly=true adds ismissing for all schema fields not in the scope
1134
+ const foundAccessRules = await accessRulesReader.findRules(
1135
+ {
1136
+ policyScope: {
1137
+ clientId: clientId,
1138
+ },
1139
+ policyScopeMatch: FilterScopeMatch.Exact,
1140
+ userScope: {
1141
+ userId: userId,
1142
+ },
1143
+ userScopeMatch: FilterScopeMatch.Exact,
1144
+ },
1145
+ true,
1146
+ );
1147
+
1148
+ // then
1149
+ expect(foundAccessRules).toEqual([accessRule]);
1150
+ });
1151
+ });
1152
+
1153
+ afterAll(async () => {
1154
+ await redisClient.flushAll();
1155
+ });
1156
+ });