← back to Unclaimed Property Platform

tests/test_cycle5_search.py

106 lines

"""Cycle 4 tests — SearchService: masked results + REAL rate limiting + fail-closed projection.

Run:  python -m tests.test_cycle5_search

Proves (deterministic, no sleeps — the limiter clock is injected):
  1. search() returns masked, projection-safe rows (no raw PII).
  2. rate limiting is REAL — N allowed per window, N+1 raises RateLimited; budget refills
     after the window passes. (Closes Security 1.3, the last open High.)
  3. empty/punctuation queries raise EmptyQuery.
  4. rate-limit budgets are per-client-key (one abuser can't exhaust another's budget).
"""
from __future__ import annotations

import sys
import tempfile
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

from services.common.rate_limit import SlidingWindowRateLimiter
from services.common.sqlite_repo import FileObjectStore, SqliteRepository
from services.ingestion.ingest import FeedDefinition, ingest_authorized_feed
from services.search.service import EmptyQuery, RateLimited, SearchService

SAMPLE = Path(__file__).resolve().parents[1] / "data" / "sample" / "sample_state_feed.csv"


def ok(msg: str) -> None:
    print(f"  ✓ {msg}")


class FakeClock:
    def __init__(self) -> None:
        self.t = 0.0

    def __call__(self) -> float:
        return self.t


def _populated_repo(tmp: Path) -> SqliteRepository:
    store = FileObjectStore(tmp / "obj")
    (tmp / "obj").mkdir(parents=True, exist_ok=True)
    store.write_bytes("incoming/s.csv", SAMPLE.read_bytes())
    repo = SqliteRepository(str(tmp / "p.db"))
    ingest_authorized_feed(FeedDefinition("SAMPLE", "incoming/s.csv"), store, repo)
    return repo


def main() -> int:
    tmp = Path(tempfile.mkdtemp(prefix="upp-cycle5-"))
    repo = _populated_repo(tmp)
    clock = FakeClock()
    limiter = SlidingWindowRateLimiter(max_requests=3, window_seconds=60, clock=clock)
    svc = SearchService(repository=repo, limiter=limiter, max_results=20)

    print("1) masked, projection-safe results")
    rows = svc.search("Oneil", client_key="ip:10.0.0.1")
    assert rows, "expected a hit"
    assert all("owner_name_masked" in r and "owner_name_raw" not in r for r in rows), rows
    assert "ONEIL" not in (rows[0]["owner_name_masked"] or "").upper(), rows[0]
    ok(f"hit {rows[0]['owner_name_masked']} | {rows[0]['amount_band']} (no raw PII)")

    print("2) REAL rate limiting (3 per 60s window, deterministic clock)")
    limiter.reset()
    k = "ip:1.2.3.4"
    for i in range(3):
        svc.search("Oneil", client_key=k)          # 3 allowed at t=0
    raised = False
    try:
        svc.search("Oneil", client_key=k)          # 4th within window -> blocked
    except RateLimited as e:
        raised = True
        assert e.retry_after > 0, e.retry_after
    assert raised, "4th request in window should have been rate-limited"
    ok("4th request in window -> RateLimited (retry_after > 0)")

    clock.t = 61.0                                   # window elapsed
    rows_after = svc.search("Oneil", client_key=k)   # budget refilled
    assert rows_after, "should be allowed again after the window"
    ok("after window elapses -> allowed again")

    print("3) empty / punctuation queries rejected")
    for bad in ("", "   ", "!!!"):
        limiter.reset()
        try:
            svc.search(bad, client_key="ip:fresh")
            raise AssertionError(f"empty query {bad!r} should raise EmptyQuery")
        except EmptyQuery:
            pass
    ok("empty/punctuation queries -> EmptyQuery")

    print("4) per-client-key budgets are independent")
    limiter.reset()
    for _ in range(3):
        svc.search("Oneil", client_key="ip:aaa")     # exhaust aaa
    # a DIFFERENT client is unaffected
    assert svc.search("Oneil", client_key="ip:bbb"), "bbb should have its own budget"
    ok("one client's exhausted budget does not block another")

    print("\nALL CYCLE-4 SEARCH-SERVICE ASSERTIONS PASSED ✅")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())