Skip to content

Commit 1c7eb98

Browse files
test(wl_ratelimit): cover KV-store helpers + reset KV path — G3 batch 2
Item G3 of v1.1 test-coverage push, second batch. Closes the wl_ratelimit coverage gap from 27% to 85% (+58pp, +73 covered lines) with 27 new unit tests across 5 new classes. Existing tests only covered the in-memory path of check_rate_limit (session_key=None branch). These new tests cover the KV-store path that's used in production (when called from the REST handler with a real session key). TestKvHelpers (6 tests): - _kv_url with/without key → URL composition - _kv_key('alice', 'write') → 'alice::write' delimiter contract - _rmw_lock_path standard input → tempdir-rooted path - _rmw_lock_path with path-traversal characters → sanitized - _rmw_lock_path('','') → documented as stable (also documents that the `_anon` sentinel branch at line 68 is currently UNREACHABLE because the implementation always joins with "_") TestKvReadTimestamps (7 tests, lines 84-117): - 200 + payload → timestamps parsed correctly - ResourceNotFound → [] - Generic Exception → [] (fail-open per docstring) - non-200 status → [] - malformed outer JSON → [] - non-numeric timestamps filtered out - non-list payload → [] TestKvWriteTimestamps (5 tests, lines 120-166): - 200 update → True - 404 update → falls through to insert (POST collection URL) - 500 update → False, no insert fallback - ResourceNotFound on update → falls to insert path - Generic Exception → False (fail-closed for writes) TestKvListAllAndDelete (7 tests, lines 169-200): - list 200 + JSON list → returned as Python list - list non-200 → [] - list non-list response → [] - list exception → [] - list malformed JSON → [] - delete swallows exception (best-effort contract) - delete uses HTTP DELETE method TestResetRateLimitsKvPath (2 tests, lines 281-287): - reset enumerates and deletes each record + clears in-memory dict - records missing '_key' are silently skipped Mock infrastructure: helper `_make_splunk_mock(status, content)` and `_patch_splunk(mock_splunk)` set up a sys.modules patch with a realistic mock_splunk.rest.simpleRequest and a real exception class for splunk.ResourceNotFound (so `except splunk.ResourceNotFound` clauses in production code have something to catch). Discovery during testing: line 68 `_anon` sentinel branch is UNREACHABLE — `user + "_" + action_type` always contains at least the literal "_", so the sanitized string is never empty. Pinned the observed behavior; flagged in the test docstring for future maintainers. Not removed (would be scope creep). Remaining wl_ratelimit gaps: lines 241-258 (file_lock + KV inside check_rate_limit) and 269-272 (auto-cleanup when >10000 keys). Deferred — both require complex setup (integration-grade Splunk mock for the file_lock+KV path; 10K synthetic keys for cleanup). Coverage delta: bin/wl_ratelimit.py 27% → 85% (+58pp). Total bin/ coverage: 2015 → 2088 covered lines. Tests pass: 671/671 unit (was 644 — added 27).
1 parent dbe8ec6 commit 1c7eb98

1 file changed

Lines changed: 325 additions & 2 deletions

File tree

tests/unit/test_ratelimit.py

Lines changed: 325 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,49 @@
44
Tests sliding-window rate limiting with per-user and per-action-type tracking.
55
"""
66

7+
import json
78
import pytest
9+
import tempfile
810
import time
9-
from unittest.mock import patch
11+
from unittest.mock import patch, MagicMock
1012

1113
# Add bin directory to path for imports
1214
import sys
1315
import os
1416
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../bin'))
1517

16-
from wl_ratelimit import check_rate_limit, reset_rate_limits, _rate_limits
18+
from wl_ratelimit import (
19+
check_rate_limit, reset_rate_limits, _rate_limits,
20+
_kv_url, _kv_key, _rmw_lock_path,
21+
_kv_read_timestamps, _kv_write_timestamps,
22+
_kv_list_all, _kv_delete_key,
23+
)
24+
25+
26+
class _FakeResourceNotFound(Exception):
27+
"""Stand-in for splunk.ResourceNotFound used inside KV mocks."""
28+
pass
29+
30+
31+
def _make_splunk_mock(status_code, content=""):
32+
"""Build a mock `splunk` module whose simpleRequest returns
33+
(status_object, content). Also defines ResourceNotFound so that
34+
`except splunk.ResourceNotFound:` clauses in the production code
35+
have a real exception class to catch."""
36+
mock_status = MagicMock()
37+
mock_status.status = status_code
38+
mock_splunk = MagicMock()
39+
mock_splunk.rest.simpleRequest.return_value = (mock_status, content)
40+
mock_splunk.ResourceNotFound = _FakeResourceNotFound
41+
return mock_splunk
42+
43+
44+
def _patch_splunk(mock_splunk):
45+
"""Helper: patch.dict on sys.modules to swap splunk + splunk.rest."""
46+
return patch.dict(
47+
'sys.modules',
48+
{'splunk': mock_splunk, 'splunk.rest': mock_splunk.rest},
49+
)
1750

1851

1952
@pytest.mark.unit
@@ -158,3 +191,293 @@ def test_empty_state_on_reset(self):
158191
# Next request should create fresh entry
159192
assert check_rate_limit("user1", "write") is True
160193
assert len(_rate_limits[("user1", "write")]) == 1
194+
195+
196+
# ═════════════════════════════════════════════════════════════════════════════
197+
# Test: KV-store helpers and KV-path of check_rate_limit / reset_rate_limits
198+
# (item G3 batch 2 coverage push, 2026-05-19)
199+
#
200+
# Covers bin/wl_ratelimit.py lines 44-200 (KV helpers) and the KV branches
201+
# at 225-258, 281-287. The in-memory path is already covered by the existing
202+
# tests above. The KV path requires mocking splunk.rest.simpleRequest and
203+
# providing a real exception class for splunk.ResourceNotFound.
204+
# ═════════════════════════════════════════════════════════════════════════════
205+
206+
207+
@pytest.mark.unit
208+
class TestKvHelpers:
209+
"""Cover the pure helpers: _kv_url, _kv_key, _rmw_lock_path."""
210+
211+
def test_kv_url_without_key_returns_collection_url(self):
212+
"""_kv_url() with no key returns the collection endpoint."""
213+
url = _kv_url()
214+
assert url.endswith("/storage/collections/data/wl_ratelimit_state")
215+
assert "/servicesNS/nobody/" in url
216+
217+
def test_kv_url_with_key_appends_to_path(self):
218+
"""_kv_url('alice::write') appends the key to the collection URL."""
219+
url = _kv_url("alice::write")
220+
assert url.endswith("/wl_ratelimit_state/alice::write")
221+
222+
def test_kv_key_composes_user_and_action_type(self):
223+
"""_kv_key uses '::' as the delimiter."""
224+
assert _kv_key("alice", "write") == "alice::write"
225+
assert _kv_key("bob", "read") == "bob::read"
226+
227+
def test_rmw_lock_path_sanitizes_unsafe_chars(self):
228+
"""Non-alphanumeric/_/./- characters in the lock path are replaced."""
229+
path = _rmw_lock_path("alice", "write")
230+
# tempfile.gettempdir() is the parent dir
231+
assert path.startswith(tempfile.gettempdir())
232+
assert path.endswith(".rmw.lock")
233+
# Standard ASCII names stay verbatim
234+
assert "alice_write" in path
235+
236+
def test_rmw_lock_path_handles_path_traversal_attempt(self):
237+
"""Slashes and dots in user input are replaced with underscores."""
238+
path = _rmw_lock_path("../../etc/passwd", "write")
239+
# The dangerous '/' is sanitized; ".." stays (dots allowed)
240+
# but cannot traverse because path is rooted in tempdir
241+
assert "/" not in os.path.basename(path).replace(os.sep, "")
242+
assert path.startswith(tempfile.gettempdir())
243+
244+
def test_rmw_lock_path_empty_user_and_action_falls_back_safely(self):
245+
"""Empty user + empty action_type produces a stable lock path.
246+
247+
Note: discovered while writing G3 batch 2 — the ``_anon`` sentinel
248+
branch in _rmw_lock_path is currently UNREACHABLE because the
249+
implementation joins `user + "_" + action_type`, so the input
250+
to the sanitizer always contains at least the literal "_", and
251+
the sanitized string is never empty. The `_anon` fallback would
252+
only fire if a future refactor removed the joining `"_"`. For
253+
now, pin the observed behavior: empty inputs produce a path
254+
rooted in tempdir ending in `.rmw.lock`.
255+
"""
256+
path = _rmw_lock_path("", "")
257+
assert path.startswith(tempfile.gettempdir())
258+
assert path.endswith(".rmw.lock")
259+
# The literal "_" between user and action_type means the sanitized
260+
# name contains exactly one underscore — pin this to document
261+
# the unreachability of the `_anon` branch.
262+
assert "wl_ratelimit__.rmw.lock" in path
263+
264+
265+
@pytest.mark.unit
266+
class TestKvReadTimestamps:
267+
"""Cover _kv_read_timestamps at bin/wl_ratelimit.py:84-117."""
268+
269+
def test_returns_timestamps_on_200(self):
270+
"""Status 200 with payload JSON list returns the parsed timestamps."""
271+
content = json.dumps({
272+
"_key": "alice::write",
273+
"payload": json.dumps([1000.0, 1001.5, 1002.7]),
274+
})
275+
mock_splunk = _make_splunk_mock(200, content)
276+
with _patch_splunk(mock_splunk):
277+
result = _kv_read_timestamps("session", "alice", "write")
278+
assert result == [1000.0, 1001.5, 1002.7]
279+
280+
def test_returns_empty_on_resource_not_found(self):
281+
"""splunk.ResourceNotFound → [] (record never written yet)."""
282+
mock_splunk = _make_splunk_mock(200, "")
283+
mock_splunk.rest.simpleRequest.side_effect = _FakeResourceNotFound("no rec")
284+
with _patch_splunk(mock_splunk):
285+
assert _kv_read_timestamps("session", "alice", "write") == []
286+
287+
def test_returns_empty_on_generic_exception(self):
288+
"""Generic Exception → [] (fail-open per docstring)."""
289+
mock_splunk = _make_splunk_mock(200, "")
290+
mock_splunk.rest.simpleRequest.side_effect = RuntimeError("network down")
291+
with _patch_splunk(mock_splunk):
292+
assert _kv_read_timestamps("session", "alice", "write") == []
293+
294+
def test_returns_empty_on_non_200_status(self):
295+
"""Any non-200 status → []."""
296+
mock_splunk = _make_splunk_mock(500, "")
297+
with _patch_splunk(mock_splunk):
298+
assert _kv_read_timestamps("session", "alice", "write") == []
299+
300+
def test_returns_empty_on_malformed_outer_json(self):
301+
"""Outer content not parseable as JSON → []."""
302+
mock_splunk = _make_splunk_mock(200, "not valid json {{{")
303+
with _patch_splunk(mock_splunk):
304+
assert _kv_read_timestamps("session", "alice", "write") == []
305+
306+
def test_filters_non_numeric_timestamps(self):
307+
"""Malformed payload entries (strings, None) are dropped."""
308+
content = json.dumps({
309+
"payload": json.dumps([1000.0, "bad_string", None, 1002.5, True]),
310+
})
311+
mock_splunk = _make_splunk_mock(200, content)
312+
with _patch_splunk(mock_splunk):
313+
result = _kv_read_timestamps("session", "alice", "write")
314+
# True is also int in Python (bool subclass) — accepted as 1.0
315+
assert 1000.0 in result and 1002.5 in result
316+
assert "bad_string" not in result
317+
assert None not in result
318+
319+
def test_returns_empty_on_non_list_payload(self):
320+
"""payload that parses as non-list → []."""
321+
content = json.dumps({"payload": json.dumps({"not": "a list"})})
322+
mock_splunk = _make_splunk_mock(200, content)
323+
with _patch_splunk(mock_splunk):
324+
assert _kv_read_timestamps("session", "alice", "write") == []
325+
326+
327+
@pytest.mark.unit
328+
class TestKvWriteTimestamps:
329+
"""Cover _kv_write_timestamps at bin/wl_ratelimit.py:120-166."""
330+
331+
def test_update_succeeds_on_200(self):
332+
"""Update POST returns 200 → True."""
333+
mock_splunk = _make_splunk_mock(200, "")
334+
with _patch_splunk(mock_splunk):
335+
assert _kv_write_timestamps("session", "alice", "write", [1.0, 2.0]) is True
336+
337+
def test_update_404_falls_through_to_insert(self):
338+
"""Update returns 404 → re-attempts as insert; success on 201."""
339+
# First call (update) returns 404; second call (insert) returns 201.
340+
mock_status_404 = MagicMock(); mock_status_404.status = 404
341+
mock_status_201 = MagicMock(); mock_status_201.status = 201
342+
mock_splunk = MagicMock()
343+
mock_splunk.ResourceNotFound = _FakeResourceNotFound
344+
mock_splunk.rest.simpleRequest.side_effect = [
345+
(mock_status_404, ""),
346+
(mock_status_201, ""),
347+
]
348+
with _patch_splunk(mock_splunk):
349+
assert _kv_write_timestamps("session", "alice", "write", [1.0]) is True
350+
# Both calls fired (update + insert)
351+
assert mock_splunk.rest.simpleRequest.call_count == 2
352+
353+
def test_update_500_returns_false_without_insert(self):
354+
"""Update returns 500 (not 404) → False, no fallback insert."""
355+
mock_splunk = _make_splunk_mock(500, "")
356+
with _patch_splunk(mock_splunk):
357+
assert _kv_write_timestamps("session", "alice", "write", [1.0]) is False
358+
# Only one call (no insert fallback for non-404)
359+
assert mock_splunk.rest.simpleRequest.call_count == 1
360+
361+
def test_update_resource_not_found_falls_to_insert(self):
362+
"""Update raises ResourceNotFound → falls to insert path."""
363+
mock_status_200 = MagicMock(); mock_status_200.status = 200
364+
mock_splunk = MagicMock()
365+
mock_splunk.ResourceNotFound = _FakeResourceNotFound
366+
# First call raises ResourceNotFound, second call (insert) returns 200
367+
mock_splunk.rest.simpleRequest.side_effect = [
368+
_FakeResourceNotFound("missing"),
369+
(mock_status_200, ""),
370+
]
371+
with _patch_splunk(mock_splunk):
372+
assert _kv_write_timestamps("session", "alice", "write", [1.0]) is True
373+
374+
def test_generic_exception_returns_false(self):
375+
"""Non-ResourceNotFound exception → False (fail-closed for writes)."""
376+
mock_splunk = _make_splunk_mock(200, "")
377+
mock_splunk.rest.simpleRequest.side_effect = RuntimeError("network down")
378+
with _patch_splunk(mock_splunk):
379+
assert _kv_write_timestamps("session", "alice", "write", [1.0]) is False
380+
381+
382+
@pytest.mark.unit
383+
class TestKvListAllAndDelete:
384+
"""Cover _kv_list_all (169-187) and _kv_delete_key (190-200)."""
385+
386+
def test_list_all_returns_records_on_200(self):
387+
"""200 + JSON list content → returned as Python list."""
388+
records = [
389+
{"_key": "alice::write", "payload": "[1.0]"},
390+
{"_key": "bob::read", "payload": "[2.0]"},
391+
]
392+
mock_splunk = _make_splunk_mock(200, json.dumps(records))
393+
with _patch_splunk(mock_splunk):
394+
result = _kv_list_all("session")
395+
assert result == records
396+
397+
def test_list_all_non_200_returns_empty(self):
398+
mock_splunk = _make_splunk_mock(500, "")
399+
with _patch_splunk(mock_splunk):
400+
assert _kv_list_all("session") == []
401+
402+
def test_list_all_non_list_response_returns_empty(self):
403+
"""200 but content parses to non-list → []."""
404+
mock_splunk = _make_splunk_mock(200, json.dumps({"unexpected": "shape"}))
405+
with _patch_splunk(mock_splunk):
406+
assert _kv_list_all("session") == []
407+
408+
def test_list_all_exception_returns_empty(self):
409+
mock_splunk = _make_splunk_mock(200, "")
410+
mock_splunk.rest.simpleRequest.side_effect = RuntimeError("down")
411+
with _patch_splunk(mock_splunk):
412+
assert _kv_list_all("session") == []
413+
414+
def test_list_all_malformed_json_returns_empty(self):
415+
mock_splunk = _make_splunk_mock(200, "not json {{{")
416+
with _patch_splunk(mock_splunk):
417+
assert _kv_list_all("session") == []
418+
419+
def test_delete_key_swallows_exception(self):
420+
"""_kv_delete_key never raises (rate-limit reset is best-effort)."""
421+
mock_splunk = _make_splunk_mock(200, "")
422+
mock_splunk.rest.simpleRequest.side_effect = RuntimeError("down")
423+
with _patch_splunk(mock_splunk):
424+
# Must not raise
425+
_kv_delete_key("session", "alice::write")
426+
427+
def test_delete_key_issues_delete_request(self):
428+
"""_kv_delete_key calls simpleRequest with method=DELETE."""
429+
mock_splunk = _make_splunk_mock(200, "")
430+
with _patch_splunk(mock_splunk):
431+
_kv_delete_key("session", "alice::write")
432+
# Verify DELETE method was used
433+
call_kwargs = mock_splunk.rest.simpleRequest.call_args.kwargs
434+
assert call_kwargs.get("method") == "DELETE"
435+
436+
437+
@pytest.mark.unit
438+
class TestResetRateLimitsKvPath:
439+
"""Cover the KV branch of reset_rate_limits at lines 281-287."""
440+
441+
def setup_method(self):
442+
reset_rate_limits()
443+
444+
def test_reset_with_session_key_deletes_each_kv_record(self):
445+
"""With session_key, reset enumerates all records and DELETEs each."""
446+
records = [
447+
{"_key": "alice::write"},
448+
{"_key": "bob::read"},
449+
]
450+
# Sequence: first call lists records (200 + JSON list),
451+
# then two DELETEs follow.
452+
mock_status_200 = MagicMock(); mock_status_200.status = 200
453+
mock_splunk = MagicMock()
454+
mock_splunk.ResourceNotFound = _FakeResourceNotFound
455+
mock_splunk.rest.simpleRequest.side_effect = [
456+
(mock_status_200, json.dumps(records)), # _kv_list_all
457+
(mock_status_200, ""), # delete alice::write
458+
(mock_status_200, ""), # delete bob::read
459+
]
460+
with _patch_splunk(mock_splunk):
461+
reset_rate_limits("session")
462+
# 1 list + 2 deletes = 3 calls
463+
assert mock_splunk.rest.simpleRequest.call_count == 3
464+
# In-memory dict is also cleared
465+
assert len(_rate_limits) == 0
466+
467+
def test_reset_with_session_skips_records_missing_key(self):
468+
"""Records without _key are silently skipped (no DELETE attempted)."""
469+
records = [
470+
{"no_key": "broken"}, # missing _key
471+
{"_key": "alice::write"},
472+
]
473+
mock_status_200 = MagicMock(); mock_status_200.status = 200
474+
mock_splunk = MagicMock()
475+
mock_splunk.ResourceNotFound = _FakeResourceNotFound
476+
mock_splunk.rest.simpleRequest.side_effect = [
477+
(mock_status_200, json.dumps(records)),
478+
(mock_status_200, ""), # only ONE delete for alice
479+
]
480+
with _patch_splunk(mock_splunk):
481+
reset_rate_limits("session")
482+
# 1 list + 1 delete (broken record skipped) = 2 calls
483+
assert mock_splunk.rest.simpleRequest.call_count == 2

0 commit comments

Comments
 (0)