@prosopo/user-access-policy 3.12.7 → 3.12.9

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 (96) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +45 -41
  2. package/.turbo/turbo-build$colon$tsc.log +14 -14
  3. package/.turbo/turbo-build.log +46 -42
  4. package/CHANGELOG.md +40 -0
  5. package/dist/.export.js +7 -21
  6. package/dist/_virtual/_rolldown/runtime.js +3 -0
  7. package/dist/api/.export.js +2 -9
  8. package/dist/api/accessRulesApiClient.js +1 -1
  9. package/dist/api/delete/.export.js +2 -1
  10. package/dist/api/delete/deleteAllRules.js +22 -24
  11. package/dist/api/delete/deleteRuleGroups.js +39 -51
  12. package/dist/api/delete/deleteRules.js +37 -41
  13. package/dist/api/read/.export.js +2 -1
  14. package/dist/api/read/fetchRules.js +34 -42
  15. package/dist/api/read/findRuleIds.js +35 -48
  16. package/dist/api/read/getMissingIds.js +36 -40
  17. package/dist/api/ruleApiRoutes.js +62 -120
  18. package/dist/api/rulesApiClient.js +51 -87
  19. package/dist/api/write/.export.js +2 -1
  20. package/dist/api/write/insertRules.js +84 -102
  21. package/dist/api/write/rehashRules.js +48 -56
  22. package/dist/cjs/.export.cjs +21 -23
  23. package/dist/cjs/_virtual/_rolldown/runtime.cjs +23 -0
  24. package/dist/cjs/api/.export.cjs +6 -10
  25. package/dist/cjs/api/accessRulesApiClient.cjs +0 -0
  26. package/dist/cjs/api/delete/.export.cjs +0 -1
  27. package/dist/cjs/api/delete/deleteAllRules.cjs +22 -24
  28. package/dist/cjs/api/delete/deleteRuleGroups.cjs +39 -51
  29. package/dist/cjs/api/delete/deleteRules.cjs +41 -42
  30. package/dist/cjs/api/read/.export.cjs +0 -1
  31. package/dist/cjs/api/read/fetchRules.cjs +34 -41
  32. package/dist/cjs/api/read/findRuleIds.cjs +37 -48
  33. package/dist/cjs/api/read/getMissingIds.cjs +36 -39
  34. package/dist/cjs/api/ruleApiRoutes.cjs +69 -125
  35. package/dist/cjs/api/rulesApiClient.cjs +54 -90
  36. package/dist/cjs/api/write/.export.cjs +0 -1
  37. package/dist/cjs/api/write/insertRules.cjs +85 -103
  38. package/dist/cjs/api/write/rehashRules.cjs +48 -56
  39. package/dist/cjs/classifyOs.cjs +46 -26
  40. package/dist/cjs/mongoose/.export.cjs +2 -3
  41. package/dist/cjs/mongoose/mongooseRuleSchema.cjs +97 -36
  42. package/dist/cjs/redis/.export.cjs +4 -5
  43. package/dist/cjs/redis/reader/redisAggregate.cjs +59 -84
  44. package/dist/cjs/redis/reader/redisRulesQuery.cjs +74 -116
  45. package/dist/cjs/redis/reader/redisRulesReader.cjs +322 -503
  46. package/dist/cjs/redis/reader/redisRulesSplitQuery.cjs +94 -72
  47. package/dist/cjs/redis/redisClient.cjs +53 -58
  48. package/dist/cjs/redis/redisRuleIndex.cjs +93 -49
  49. package/dist/cjs/redis/redisRulesStorage.cjs +21 -30
  50. package/dist/cjs/redis/redisRulesWriter.cjs +86 -112
  51. package/dist/cjs/rule.cjs +8 -7
  52. package/dist/cjs/ruleInput/.export.cjs +7 -8
  53. package/dist/cjs/ruleInput/policyInput.cjs +36 -39
  54. package/dist/cjs/ruleInput/ruleInput.cjs +37 -42
  55. package/dist/cjs/ruleInput/userScopeInput.cjs +47 -55
  56. package/dist/cjs/ruleRecord.cjs +15 -22
  57. package/dist/cjs/rulesStorage.cjs +7 -6
  58. package/dist/cjs/transformRule.cjs +36 -70
  59. package/dist/classifyOs.js +48 -29
  60. package/dist/mongoose/.export.js +2 -3
  61. package/dist/mongoose/mongooseRuleSchema.js +99 -38
  62. package/dist/redis/.export.js +3 -5
  63. package/dist/redis/reader/redisAggregate.js +60 -86
  64. package/dist/redis/reader/redisRulesQuery.js +75 -118
  65. package/dist/redis/reader/redisRulesReader.js +312 -487
  66. package/dist/redis/reader/redisRulesSplitQuery.js +96 -75
  67. package/dist/redis/redisClient.js +54 -62
  68. package/dist/redis/redisRuleIndex.js +93 -58
  69. package/dist/redis/redisRulesStorage.js +21 -30
  70. package/dist/redis/redisRulesWriter.js +82 -113
  71. package/dist/rule.js +10 -10
  72. package/dist/ruleInput/.export.js +3 -7
  73. package/dist/ruleInput/policyInput.d.ts +52 -1
  74. package/dist/ruleInput/policyInput.d.ts.map +1 -1
  75. package/dist/ruleInput/policyInput.js +35 -41
  76. package/dist/ruleInput/policyInput.js.map +1 -1
  77. package/dist/ruleInput/ruleInput.d.ts.map +1 -1
  78. package/dist/ruleInput/ruleInput.js +36 -44
  79. package/dist/ruleInput/ruleInput.js.map +1 -1
  80. package/dist/ruleInput/userScopeInput.js +44 -55
  81. package/dist/ruleRecord.js +17 -27
  82. package/dist/rulesStorage.js +9 -8
  83. package/dist/tests/policyInput.unit.test.js +50 -1
  84. package/dist/tests/policyInput.unit.test.js.map +1 -1
  85. package/dist/tests/redis/redisRulesStorage.integration.test.js +22 -3
  86. package/dist/tests/redis/redisRulesStorage.integration.test.js.map +1 -1
  87. package/dist/transformRule.js +34 -73
  88. package/dist/transformRule.js.map +1 -1
  89. package/entries.ts +25 -6
  90. package/package.json +13 -12
  91. package/src/ruleInput/policyInput.ts +45 -2
  92. package/src/ruleInput/ruleInput.ts +10 -3
  93. package/src/tests/policyInput.unit.test.ts +69 -1
  94. package/src/tests/redis/redisRulesStorage.integration.test.ts +191 -148
  95. package/src/transformRule.ts +2 -2
  96. package/tsconfig.tsbuildinfo +1 -1
@@ -27,7 +27,24 @@ import {
27
27
  // (preprocess accepts anything), which fails the
28
28
  // `ZodType<T, ZodTypeDef, T>` identity check. The `AllKeys<AccessPolicy>`
29
29
  // constraint still catches missing-field regressions.
30
- export const accessPolicyInput = z.object({
30
+ //
31
+ // The `superRefine` rejects Block+captchaType and Block+solvedImagesCount
32
+ // at write time. Those fields are meaningless on a Block policy — the
33
+ // sanitizer used to silently strip them, which meant operators writing
34
+ // e.g. "block image captchas for this IP" got a rule that actually
35
+ // blocked EVERY captcha type. The bug in #2885 (Block+deferToVerify 400
36
+ // on every /captcha/*) was a knock-on of the same silent-strip: the
37
+ // captchaType-equality check on the read side compared `undefined` to the
38
+ // request's captchaType and rejected. Rejecting at input surfaces the
39
+ // mismatch loudly so operators can adjust their intent (they usually
40
+ // wanted a Restrict) rather than persisting a rule that doesn't match
41
+ // what they typed.
42
+ // Base ZodObject shape — reused via `.shape` by `ruleInput` and
43
+ // `transformRule` (both compose accessPolicyInput's fields with policy /
44
+ // user scope fields via object spread). Split from `accessPolicyInput`
45
+ // below because that one applies a `.superRefine` and returns a
46
+ // `ZodEffects`, which loses `.shape`.
47
+ export const accessPolicyInputShape = z.object({
31
48
  type: z.nativeEnum(AccessPolicyType),
32
49
  captchaType: CaptchaTypeSchema.optional(),
33
50
  description: z.coerce.string().optional(),
@@ -49,7 +66,33 @@ export const accessPolicyInput = z.object({
49
66
  .optional(),
50
67
  } satisfies AllKeys<AccessPolicy>);
51
68
 
52
- // Sanitize block policies by removing captchaType and solvedImagesCount
69
+ export const accessPolicyInput = accessPolicyInputShape.superRefine(
70
+ (policy, ctx) => {
71
+ if (policy.type !== AccessPolicyType.Block) return;
72
+ if (policy.captchaType !== undefined) {
73
+ ctx.addIssue({
74
+ code: z.ZodIssueCode.custom,
75
+ path: ["captchaType"],
76
+ message:
77
+ "Block policies cannot pin a captchaType — Block always applies to every captcha type. Use a Restrict policy if you want to narrow the effect to one captcha type.",
78
+ });
79
+ }
80
+ if (policy.solvedImagesCount !== undefined) {
81
+ ctx.addIssue({
82
+ code: z.ZodIssueCode.custom,
83
+ path: ["solvedImagesCount"],
84
+ message:
85
+ "Block policies cannot set solvedImagesCount — the field only applies to Restrict policies that adjust image-captcha rounds.",
86
+ });
87
+ }
88
+ },
89
+ );
90
+
91
+ // Historical helper: strip fields the sanitizer used to remove silently.
92
+ // Kept as a no-op-in-practice safety net because Redis records written
93
+ // before the superRefine on `accessPolicyInput` (above) may still carry
94
+ // the stripped fields — reading them back should still normalize to the
95
+ // canonical shape. New writes are rejected at input.
53
96
  export const sanitizeAccessPolicy = (policy: AccessPolicy): AccessPolicy => {
54
97
  if (policy.type === AccessPolicyType.Block) {
55
98
  const { captchaType, solvedImagesCount, ...blockPolicy } = policy;
@@ -20,7 +20,7 @@ import {
20
20
  type AccessRulesFilter,
21
21
  FilterScopeMatch,
22
22
  } from "#policy/rulesStorage.js";
23
- import { accessPolicyInput, policyScopeInput } from "./policyInput.js";
23
+ import { accessPolicyInputShape, policyScopeInput } from "./policyInput.js";
24
24
  import { type UserScopeInput, userScopeInput } from "./userScopeInput.js";
25
25
 
26
26
  type RuleGroupInput = {
@@ -49,13 +49,20 @@ const ruleGroupInput = z
49
49
  });
50
50
 
51
51
  // Explicit `ZodType<…, ZodTypeDef, unknown>` annotation rather than the
52
- // strict-identity form because `accessPolicyInput.shape.deferToVerify`
52
+ // strict-identity form because `accessPolicyInputShape.shape.deferToVerify`
53
53
  // uses `z.preprocess` which widens the input position to `unknown`. The
54
54
  // relaxed annotation is portable for declaration emit; the `transform`
55
55
  // pins the OUTPUT to AccessRule.
56
+ //
57
+ // Uses the unrefined `accessPolicyInputShape` (not `accessPolicyInput`)
58
+ // because the API-write refinement rejecting Block+captchaType is a
59
+ // write-time input guard — the READ path (Redis reader → accessRuleInput
60
+ // parse) must still accept the legacy shapes for records written before
61
+ // the refinement landed. Otherwise the reader would throw on every
62
+ // pre-existing Block rule that carried a stripped captchaType field.
56
63
  export const accessRuleInput: ZodType<AccessRule, z.ZodTypeDef, unknown> = z
57
64
  .object({
58
- ...accessPolicyInput.shape,
65
+ ...accessPolicyInputShape.shape,
59
66
  ...policyScopeInput.shape,
60
67
  })
61
68
  .and(userScopeInput)
@@ -15,7 +15,10 @@
15
15
  import { CaptchaType } from "@prosopo/types";
16
16
  import { describe, expect, it } from "vitest";
17
17
  import { AccessPolicyType } from "#policy/rule.js";
18
- import { sanitizeAccessPolicy } from "#policy/ruleInput/policyInput.js";
18
+ import {
19
+ accessPolicyInput,
20
+ sanitizeAccessPolicy,
21
+ } from "#policy/ruleInput/policyInput.js";
19
22
 
20
23
  describe("sanitizeAccessPolicy", () => {
21
24
  describe("block policies", () => {
@@ -148,3 +151,68 @@ describe("sanitizeAccessPolicy", () => {
148
151
  });
149
152
  });
150
153
  });
154
+
155
+ // Write-time input schema — reject the fields the sanitiser used to
156
+ // silently strip. The prior "silently strip" behaviour meant operators
157
+ // writing `--block --captchaType image` got a rule that blocked EVERY
158
+ // captcha type (not just image); rejecting at input surfaces the
159
+ // mismatch so they can switch to a Restrict policy instead.
160
+ describe("accessPolicyInput refinement", () => {
161
+ it("rejects Block + captchaType with a clear message pointing at Restrict", () => {
162
+ const result = accessPolicyInput.safeParse({
163
+ type: AccessPolicyType.Block,
164
+ captchaType: CaptchaType.image,
165
+ });
166
+ expect(result.success).toBe(false);
167
+ if (!result.success) {
168
+ const issue = result.error.issues.find((i) =>
169
+ i.path.includes("captchaType"),
170
+ );
171
+ expect(issue).toBeDefined();
172
+ expect(issue?.message).toMatch(/Block policies cannot pin a captchaType/);
173
+ expect(issue?.message).toMatch(/Restrict policy/);
174
+ }
175
+ });
176
+
177
+ it("rejects Block + solvedImagesCount", () => {
178
+ const result = accessPolicyInput.safeParse({
179
+ type: AccessPolicyType.Block,
180
+ solvedImagesCount: 3,
181
+ });
182
+ expect(result.success).toBe(false);
183
+ if (!result.success) {
184
+ const issue = result.error.issues.find((i) =>
185
+ i.path.includes("solvedImagesCount"),
186
+ );
187
+ expect(issue).toBeDefined();
188
+ expect(issue?.message).toMatch(
189
+ /Block policies cannot set solvedImagesCount/,
190
+ );
191
+ }
192
+ });
193
+
194
+ it("accepts Block without the disallowed fields", () => {
195
+ const result = accessPolicyInput.safeParse({
196
+ type: AccessPolicyType.Block,
197
+ description: "clean block",
198
+ });
199
+ expect(result.success).toBe(true);
200
+ });
201
+
202
+ it("accepts Block + deferToVerify (deferToVerify is orthogonal to the type-pin restriction)", () => {
203
+ const result = accessPolicyInput.safeParse({
204
+ type: AccessPolicyType.Block,
205
+ deferToVerify: true,
206
+ });
207
+ expect(result.success).toBe(true);
208
+ });
209
+
210
+ it("accepts Restrict + captchaType + solvedImagesCount (these are Restrict's whole point)", () => {
211
+ const result = accessPolicyInput.safeParse({
212
+ type: AccessPolicyType.Restrict,
213
+ captchaType: CaptchaType.image,
214
+ solvedImagesCount: 5,
215
+ });
216
+ expect(result.success).toBe(true);
217
+ });
218
+ });
@@ -113,176 +113,219 @@ describe("redisAccessRulesStorage", () => {
113
113
  redisClient = await result.getClient();
114
114
  });
115
115
 
116
- describe(
117
- "writer",
118
- () => {
119
- let accessRulesWriter: AccessRulesWriter;
116
+ describe("writer", () => {
117
+ let accessRulesWriter: AccessRulesWriter;
120
118
 
121
- beforeAll(() => {
122
- accessRulesWriter = new RedisRulesWriter(redisClient, mockLogger);
123
- });
119
+ beforeAll(() => {
120
+ accessRulesWriter = new RedisRulesWriter(redisClient, mockLogger);
121
+ });
124
122
 
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);
123
+ test("inserts rule", async () => {
124
+ const testIndexName = indexName;
125
+ // given
126
+ const accessRule: AccessRule = {
127
+ type: AccessPolicyType.Block,
128
+ clientId: "clientId",
129
+ };
130
+ const accessRuleKey = getAccessRuleRedisKey(accessRule);
133
131
 
134
- // when
135
- await accessRulesWriter.insertRules([
136
- {
137
- rule: accessRule,
138
- },
139
- ]);
132
+ // when
133
+ await accessRulesWriter.insertRules([
134
+ {
135
+ rule: accessRule,
136
+ },
137
+ ]);
140
138
 
141
- // then
142
- const insertedAccessRule = await redisClient.hGetAll(accessRuleKey);
143
- const indexRecordsCount = await getIndexRecordsCount(testIndexName);
139
+ // then
140
+ const insertedAccessRule = await redisClient.hGetAll(accessRuleKey);
141
+ const indexRecordsCount = await getIndexRecordsCount(testIndexName);
144
142
 
145
- expect(insertedAccessRule).toEqual(accessRule);
146
- expect(indexRecordsCount).toEqual(1);
147
- });
143
+ expect(insertedAccessRule).toEqual(accessRule);
144
+ expect(indexRecordsCount).toEqual(1);
145
+ });
148
146
 
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
- );
147
+ test("inserts time limited rule", async () => {
148
+ // given
149
+ const accessRule: AccessRule = {
150
+ type: AccessPolicyType.Block,
151
+ clientId: "clientId",
152
+ };
153
+ const accessRuleKey = getAccessRuleRedisKey(accessRule);
154
+ // 1 hour from now.
155
+ const expireIn = 60 * 60; // seconds
156
+ const expirationTimestamp = new Date(
157
+ Date.now() + expireIn * 1000,
158
+ ).getTime();
159
+ const expirationTimestampInSeconds = Math.floor(
160
+ expirationTimestamp / 1000,
161
+ );
164
162
 
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);
163
+ // when
164
+ await accessRulesWriter.insertRules([
165
+ {
166
+ rule: accessRule,
167
+ expiresUnixTimestamp: expirationTimestampInSeconds,
168
+ },
169
+ ]);
170
+ const ruleKey = getAccessRuleRedisKey(accessRule);
171
+ // then
172
+ const insertedAccessRule = await redisClient.hGetAll(accessRuleKey);
173
+ const insertedExpirationResult = await redisClient.expireAt(
174
+ ruleKey,
175
+ expirationTimestampInSeconds,
176
+ );
177
+ const indexRecordsCount = await getIndexRecordsCount(indexName);
180
178
 
181
- const recordExpirySeconds = await redisClient.ttl(ruleKey);
179
+ const recordExpirySeconds = await redisClient.ttl(ruleKey);
182
180
 
183
- expect(insertedAccessRule).toEqual(accessRule);
184
- expect(insertedExpirationResult).toBe(1);
185
- expect(recordExpirySeconds).toBeLessThanOrEqual(
186
- expirationTimestampInSeconds,
187
- );
181
+ expect(insertedAccessRule).toEqual(accessRule);
182
+ expect(insertedExpirationResult).toBe(1);
183
+ expect(recordExpirySeconds).toBeLessThanOrEqual(
184
+ expirationTimestampInSeconds,
185
+ );
188
186
 
189
- expect(indexRecordsCount).toBe(1);
190
- });
187
+ expect(indexRecordsCount).toBe(1);
188
+ });
191
189
 
192
- test("deletes rules", async () => {
193
- // given
194
- const johnAccessRule: AccessRule = {
195
- type: AccessPolicyType.Block,
196
- clientId: getUniqueString(),
197
- };
198
- const johnAccessRuleKey = getAccessRuleRedisKey(johnAccessRule);
190
+ // The existing "inserts time limited rule" test above only
191
+ // checks that the TTL is *set* on the Redis key. Neither
192
+ // side actually verifies the rule stops matching once its
193
+ // TTL fires — which is the property operators care about
194
+ // (temporary bans should self-clear). Guard against a
195
+ // regression that persists rules past their expiry (e.g. a
196
+ // future writer that swaps `expireAt` for `persist` by
197
+ // accident, or an index rebuild that resurrects expired
198
+ // records).
199
+ test("expired rule is no longer returned by the reader after its TTL fires", async () => {
200
+ const accessRule: AccessRule = {
201
+ type: AccessPolicyType.Block,
202
+ clientId: `expiring-${getUniqueString()}`,
203
+ };
204
+ const ruleKey = getAccessRuleRedisKey(accessRule);
205
+ // 2 s expiry — long enough that the "before expiry" check
206
+ // races the write cleanly, short enough that the test
207
+ // completes in a reasonable timeout. Redis TTL granularity
208
+ // is 1 s so anything shorter is under-resolution.
209
+ const expirationTimestampInSeconds = Math.floor(Date.now() / 1000) + 2;
199
210
 
200
- const doeAccessRule: AccessRule = {
201
- type: AccessPolicyType.Block,
202
- clientId: getUniqueString(),
203
- };
204
- const doeAccessRuleKey = getAccessRuleRedisKey(doeAccessRule);
211
+ await accessRulesWriter.insertRules([
212
+ {
213
+ rule: accessRule,
214
+ expiresUnixTimestamp: expirationTimestampInSeconds,
215
+ },
216
+ ]);
205
217
 
206
- await insertRules([johnAccessRule, doeAccessRule]);
218
+ // Sanity: the rule exists immediately after write.
219
+ const initialRecord = await redisClient.hGetAll(ruleKey);
220
+ expect(initialRecord).toEqual(accessRule);
207
221
 
208
- // when
209
- await accessRulesWriter.deleteRules([
210
- johnAccessRuleKey.slice(ACCESS_RULE_REDIS_KEY_PREFIX.length),
211
- ]);
222
+ // Wait past the TTL. Add ~1s slack for Redis's per-key
223
+ // expiry sweep — the docs guarantee expired keys stop
224
+ // answering reads but the actual key deletion is lazy.
225
+ await new Promise((resolve) => setTimeout(resolve, 3500));
212
226
 
213
- // then
214
- const presentAccessRule = await redisClient.hGetAll(doeAccessRuleKey);
215
- const indexRecordsCount = await getIndexRecordsCount(indexName);
227
+ // After expiry: hGetAll returns {} (Redis treats an
228
+ // expired key as non-existent for reads).
229
+ const expiredRecord = await redisClient.hGetAll(ruleKey);
230
+ expect(expiredRecord).toEqual({});
216
231
 
217
- expect(presentAccessRule).toEqual(doeAccessRule);
218
- expect(indexRecordsCount).toBe(1);
219
- });
232
+ // And the RediSearch index no longer counts it.
233
+ const indexRecordsCount = await getIndexRecordsCount(indexName);
234
+ expect(indexRecordsCount).toBe(0);
235
+ }, 10_000);
220
236
 
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
- };
237
+ test("deletes rules", async () => {
238
+ // given
239
+ const johnAccessRule: AccessRule = {
240
+ type: AccessPolicyType.Block,
241
+ clientId: getUniqueString(),
242
+ };
243
+ const johnAccessRuleKey = getAccessRuleRedisKey(johnAccessRule);
231
244
 
232
- await insertRules([johnAccessRule, doeAccessRule]);
245
+ const doeAccessRule: AccessRule = {
246
+ type: AccessPolicyType.Block,
247
+ clientId: getUniqueString(),
248
+ };
249
+ const doeAccessRuleKey = getAccessRuleRedisKey(doeAccessRule);
233
250
 
234
- // when
235
- await accessRulesWriter.deleteAllRules();
251
+ await insertRules([johnAccessRule, doeAccessRule]);
236
252
 
237
- // then
238
- const indexRecordsCount = await getIndexRecordsCount(indexName);
253
+ // when
254
+ await accessRulesWriter.deleteRules([
255
+ johnAccessRuleKey.slice(ACCESS_RULE_REDIS_KEY_PREFIX.length),
256
+ ]);
239
257
 
240
- expect(indexRecordsCount).toBe(0);
241
- });
258
+ // then
259
+ const presentAccessRule = await redisClient.hGetAll(doeAccessRuleKey);
260
+ const indexRecordsCount = await getIndexRecordsCount(indexName);
242
261
 
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
- );
262
+ expect(presentAccessRule).toEqual(doeAccessRule);
263
+ expect(indexRecordsCount).toBe(1);
264
+ });
265
+
266
+ test("deletes all rules", async () => {
267
+ // given
268
+ const johnAccessRule: AccessRule = {
269
+ type: AccessPolicyType.Block,
270
+ clientId: getUniqueString(),
271
+ };
272
+ const doeAccessRule: AccessRule = {
273
+ type: AccessPolicyType.Block,
274
+ clientId: getUniqueString(),
275
+ };
276
+
277
+ await insertRules([johnAccessRule, doeAccessRule]);
278
+
279
+ // when
280
+ await accessRulesWriter.deleteAllRules();
281
+
282
+ // then
283
+ const indexRecordsCount = await getIndexRecordsCount(indexName);
284
+
285
+ expect(indexRecordsCount).toBe(0);
286
+ });
287
+
288
+ test("deletes all rules when there are 1 million rules", async () => {
289
+ // given
290
+ const rulesCount = 1_000_000;
291
+ const batchSize = 10_000;
292
+ const numBatches = Math.ceil(rulesCount / batchSize);
293
+
294
+ // Insert rules in batches to avoid memory exhaustion
295
+ // Don't create 1M objects in memory at once!
296
+ for (let i = 0; i < numBatches; i++) {
297
+ const currentBatchSize = Math.min(
298
+ batchSize,
299
+ rulesCount - i * batchSize,
300
+ );
301
+ const batchRules: AccessRule[] = Array.from(
302
+ { length: currentBatchSize },
303
+ () => ({
304
+ type: AccessPolicyType.Block,
305
+ clientId: getUniqueString(),
306
+ }),
307
+ );
308
+
309
+ await insertRules(batchRules);
310
+ }
311
+
312
+ // verify that there are 1 million rules in the database
313
+ const beforeDeleteIndexRecordsCount =
314
+ await getIndexRecordsCount(indexName);
315
+ expect(beforeDeleteIndexRecordsCount).toBe(rulesCount);
316
+
317
+ // when
318
+ await accessRulesWriter.deleteAllRules();
319
+
320
+ // then
321
+ const afterDeleteIndexRecordsCount =
322
+ await getIndexRecordsCount(indexName);
323
+
324
+ expect(afterDeleteIndexRecordsCount).toBe(0);
325
+ });
326
+ // The trailing 240_000 is the suite timeout. vitest 4 dropped the
327
+ // options-object overload for describe, so it has to be a plain number.
328
+ }, 240_000);
286
329
 
287
330
  describe("reader", () => {
288
331
  let accessRulesReader: AccessRulesReader;
@@ -18,7 +18,7 @@ import { Address4, Address6 } from "ip-address";
18
18
  import { z } from "zod";
19
19
  import type { AccessRule } from "./rule.js";
20
20
  import {
21
- accessPolicyInput,
21
+ accessPolicyInputShape,
22
22
  policyScopeInput,
23
23
  } from "./ruleInput/policyInput.js";
24
24
  import { accessRuleInput } from "./ruleInput/ruleInput.js";
@@ -56,7 +56,7 @@ export const transformAccessRuleIntoRecord = (
56
56
 
57
57
  const accessRuleToRecordScheme = z
58
58
  .object({
59
- ...accessPolicyInput.shape,
59
+ ...accessPolicyInputShape.shape,
60
60
  ...policyScopeInput.shape,
61
61
  ...userScopeSchema.shape,
62
62
  groupId: z.coerce.string().optional(),