@prosopo/user-access-policy 3.12.5 → 3.12.6

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.
@@ -0,0 +1,121 @@
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 type { RedisClientType } from "redis";
16
+ import { describe, expect, it, vi } from "vitest";
17
+ import { searchRedisKeysBounded } from "#policy/redis/reader/redisAggregate.js";
18
+
19
+ // Match the search-side reply shape produced by
20
+ // `@redis/search`'s `SEARCH_NOCONTENT.transformReply` — a flat
21
+ // `[total, key1, key2, ...]` collapsed into `{ total, documents }`.
22
+ // This shape is the whole point of switching the split-query path to
23
+ // NOCONTENT: unlike the aggregate + LOAD @__key path it can't hit the
24
+ // null-tuple crash in `transformTuplesReply` that fires against
25
+ // field:ja4Hash probes in prod.
26
+ type SearchNoContentReply = { total: number; documents: string[] };
27
+
28
+ type FakeSearchClient = {
29
+ ft: {
30
+ searchNoContent: (
31
+ index: string,
32
+ query: string,
33
+ options: { DIALECT?: number; LIMIT?: { from: number; size: number } },
34
+ ) => Promise<SearchNoContentReply>;
35
+ };
36
+ };
37
+
38
+ const makeClient = (reply: SearchNoContentReply): FakeSearchClient => ({
39
+ ft: {
40
+ searchNoContent: vi.fn().mockResolvedValue(reply),
41
+ },
42
+ });
43
+
44
+ describe("searchRedisKeysBounded", () => {
45
+ it("returns the flat documents list from FT.SEARCH NOCONTENT", async () => {
46
+ const client = makeClient({
47
+ total: 3,
48
+ documents: ["access_rule:a", "access_rule:b", "access_rule:c"],
49
+ });
50
+
51
+ // The caller uses a bounded max — 500 in production.
52
+ const keys = await searchRedisKeysBounded(
53
+ client as unknown as RedisClientType,
54
+ "@type:{block} @ja4Hash:{t13d_probe}",
55
+ 500,
56
+ );
57
+
58
+ expect(keys).toEqual(["access_rule:a", "access_rule:b", "access_rule:c"]);
59
+ });
60
+
61
+ it("passes the caller's cap through as the FT.SEARCH LIMIT size", async () => {
62
+ const searchNoContent = vi
63
+ .fn()
64
+ .mockResolvedValue({ total: 0, documents: [] });
65
+ const client = {
66
+ ft: { searchNoContent },
67
+ } as unknown as RedisClientType;
68
+
69
+ await searchRedisKeysBounded(client, "@ja4Hash:{x}", 500);
70
+
71
+ expect(searchNoContent).toHaveBeenCalledTimes(1);
72
+ const call = searchNoContent.mock.calls[0];
73
+ expect(call).toBeDefined();
74
+ const options = call?.[2] as {
75
+ DIALECT?: number;
76
+ LIMIT?: { from: number; size: number };
77
+ };
78
+ expect(options.LIMIT).toEqual({ from: 0, size: 500 });
79
+ // DIALECT 2 is required by the ismissing() clauses the split
80
+ // query builder emits.
81
+ expect(options.DIALECT).toBe(2);
82
+ });
83
+
84
+ it("returns an empty list when the search matches nothing", async () => {
85
+ const client = makeClient({ total: 0, documents: [] });
86
+
87
+ const keys = await searchRedisKeysBounded(
88
+ client as unknown as RedisClientType,
89
+ "@ja4Hash:{never_indexed}",
90
+ 500,
91
+ );
92
+
93
+ expect(keys).toEqual([]);
94
+ });
95
+
96
+ // Regression: the aggregate + LOAD path this helper replaces throws
97
+ // TypeError: Cannot read properties of null (reading 'length')
98
+ // when the RediSearch coordinator returns a `null` result row (seen
99
+ // continuously on field:ja4Hash probes in prod, thousands per hour).
100
+ // The NOCONTENT reply carries no nested tuples, so a well-formed
101
+ // reply — even with an unexpected trailing null in the raw wire array
102
+ // — cannot reach the tuples decoder at all. Assert we return the
103
+ // documents list as-is without introspecting individual entries.
104
+ it("does not iterate individual document tuples (null-safe reply shape)", async () => {
105
+ // Simulate a bare-string documents array — the shape produced by
106
+ // SEARCH_NOCONTENT. Every entry is a string key; there are no
107
+ // per-doc tuples for a `.length` lookup to trip over.
108
+ const client = makeClient({
109
+ total: 2,
110
+ documents: ["access_rule:one", "access_rule:two"],
111
+ });
112
+
113
+ await expect(
114
+ searchRedisKeysBounded(
115
+ client as unknown as RedisClientType,
116
+ "@ja4Hash:{t13d2013h2_a09f3c656075_7f0f34a4126d}",
117
+ 500,
118
+ ),
119
+ ).resolves.toEqual(["access_rule:one", "access_rule:two"]);
120
+ });
121
+ });