File size: 13,142 Bytes
7d7841f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
"""serialized bot probes with 429 retry.

These tests target :func:`app.checks.bot_access._probe_bots` and
:func:`app.checks.bot_access._probe_one_bot` directly so we can assert the
new pacing + retry contract without spinning up the full check or doing real
HTTP. Sequencing is verified via mock side effects (``call_in_flight`` and
``call_order``) rather than wall-clock timing, because we mock
``asyncio.sleep`` and do not want the suite to take ~4.5 real seconds.
"""

import asyncio
import sys
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch

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

from app.checks import bot_access
from app.checks.bot_access import (
    _RETRY_DELAY,
    _STAGGER_DELAY,
    _probe_bots,
    _probe_one_bot,
)
from app.fetching import FetchResult
from backend.tests.fixtures import EXAMPLE_URL, make_audit_context, make_fetch_result


# A small deterministic matrix β€” we do not need the full 9-bot registry here.
# The production code reads ``_MATRIX_BOTS`` from the registry, but for these
# tests the function takes the matrix as an argument so we can keep the test
# small and explicit.
_TEST_MATRIX = ["BotA", "BotB", "BotC"]
_TEST_USER_AGENTS = {
    "BotA": "BotA/1.0 (+https://example.com/a)",
    "BotB": "BotB/1.0 (+https://example.com/b)",
    "BotC": "BotC/1.0 (+https://example.com/c)",
}


def _ok_fetch(url: str = EXAMPLE_URL, status_code: int = 200, text: str = "<html><body>ok</body></html>") -> FetchResult:
    """Return a minimal successful ``FetchResult`` for the test url."""
    return make_fetch_result(url, text, status_code=status_code)


def _body_with_words(word_count: int = 100) -> str:
    """Return a body with at least ``word_count`` words so the thin-response
    check in ``_blocked_reason`` does not flag it as blocked.
    """
    sentence = "Accessible homepage content for agents and search. "
    repeats = max(1, word_count // len(sentence.split()))
    return "<html><body>" + sentence * repeats + "</body></html>"


def _baseline() -> tuple[FetchResult, bot_access.RoutingSnapshot, int]:
    """Return a (baseline_fetch, baseline_snapshot, baseline_words) triple."""
    fetch = _ok_fetch(text=_body_with_words(120))
    snapshot = bot_access._routing_snapshot(fetch)
    return fetch, snapshot, snapshot.visible_words


class ProbeBotsSequentialTests(unittest.IsolatedAsyncioTestCase):
    """Probes must run sequentially with a stagger between each, not via ``gather``."""

    async def test_probes_execute_sequentially_not_concurrently(self) -> None:
        """With ``asyncio.gather`` the in-flight count would hit the matrix length.
        With the new serial loop it should never exceed 1.
        """
        baseline_fetch, baseline_snapshot, baseline_words = _baseline()
        call_order: list[str] = []
        in_flight = 0
        max_in_flight = 0
        started = asyncio.Event()
        can_finish = asyncio.Event()
        bot_body = _body_with_words(120)

        async def fake_fetch(url: str, user_agent: str = "", **_kwargs: object) -> FetchResult:
            nonlocal in_flight, max_in_flight
            in_flight += 1
            max_in_flight = max(max_in_flight, in_flight)
            call_order.append(f"start:{user_agent}")
            if len(call_order) == 1:
                # First probe: signal start, then hold until released. This
                # gives any subsequent (concurrent) probe a chance to run
                # before we return, which ``gather`` would exploit. With the
                # new serial loop no second probe enters until ``can_finish``
                # is set and we exit.
                started.set()
                await can_finish.wait()
            call_order.append(f"end:{user_agent}")
            in_flight -= 1
            return _ok_fetch(text=bot_body)

        with (
            patch("app.checks.bot_access.fetch_url", new=fake_fetch),
            patch("app.checks.bot_access.asyncio.sleep", new=AsyncMock()),
        ):
            task = asyncio.create_task(
                _probe_bots(
                    EXAMPLE_URL,
                    _TEST_USER_AGENTS,
                    _TEST_MATRIX,
                    baseline_fetch=baseline_fetch,
                    baseline_snapshot=baseline_snapshot,
                    baseline_words=baseline_words,
                )
            )
            # Give the first probe time to enter and signal.
            await started.wait()
            # Tiny yield to let any rogue concurrent probe sneak in. Under
            # ``asyncio.gather`` it would; under the serial loop it would not.
            await asyncio.sleep(0)
            await asyncio.sleep(0)
            self.assertEqual(in_flight, 1, "Second probe ran while the first was still in-flight (gather burst!)")
            can_finish.set()
            blocked, routing_differences, accessible_count = await task

        # Under the serial contract, max concurrency is exactly 1.
        self.assertEqual(max_in_flight, 1)
        # All 3 probes finished.
        self.assertEqual(accessible_count, 3)
        self.assertEqual(blocked, [])
        self.assertEqual(routing_differences, [])
        # The call order must interleave start/end for each bot (no two
        # starts in a row before any end), proving serial execution.
        starts = [c for c in call_order if c.startswith("start:")]
        ends = [c for c in call_order if c.startswith("end:")]
        self.assertEqual(starts[0], ends[0].replace("end:", "start:"))
        self.assertEqual(len(starts), len(_TEST_MATRIX))
        self.assertEqual(len(ends), len(_TEST_MATRIX))

    async def test_stagger_delay_applied_between_probes(self) -> None:
        """``asyncio.sleep`` is called with ``_STAGGER_DELAY`` between probes,
        exactly ``len(matrix) - 1`` times in the initial wave.
        """
        baseline_fetch, baseline_snapshot, baseline_words = _baseline()
        sleep_calls: list[float] = []

        async def fake_sleep(seconds: float) -> None:
            sleep_calls.append(seconds)

        async def fake_fetch(url: str, user_agent: str = "", **_kwargs: object) -> FetchResult:
            return _ok_fetch(text=_body_with_words(120))

        with (
            patch("app.checks.bot_access.fetch_url", new=fake_fetch),
            patch("app.checks.bot_access.asyncio.sleep", new=fake_sleep),
        ):
            await _probe_bots(
                EXAMPLE_URL,
                _TEST_USER_AGENTS,
                _TEST_MATRIX,
                baseline_fetch=baseline_fetch,
                baseline_snapshot=baseline_snapshot,
                baseline_words=baseline_words,
            )

        # 3 probes -> 2 staggers between them.
        self.assertEqual(sleep_calls, [_STAGGER_DELAY] * (len(_TEST_MATRIX) - 1))
        # Sanity: the stagger constant is the configured 0.5s.
        self.assertEqual(_STAGGER_DELAY, 0.5)


class ProbeOneBotRetryTests(unittest.IsolatedAsyncioTestCase):
    """HTTP 429 must trigger exactly one retry after ``_RETRY_DELAY``."""

    async def test_429_first_call_retried_uses_second_result(self) -> None:
        """First call returns 429, second returns 200. The retry result is used."""
        call_log: list[str] = []
        sleep_log: list[float] = []

        async def fake_fetch(url: str, user_agent: str = "", **_kwargs: object) -> FetchResult:
            call_log.append(user_agent)
            if len(call_log) == 1:
                return _ok_fetch(status_code=429)
            return _ok_fetch(status_code=200)

        async def fake_sleep(seconds: float) -> None:
            sleep_log.append(seconds)

        with (
            patch("app.checks.bot_access.fetch_url", new=fake_fetch),
            patch("app.checks.bot_access.asyncio.sleep", new=fake_sleep),
        ):
            result = await _probe_one_bot(EXAMPLE_URL, _TEST_USER_AGENTS["BotA"])

        # Exactly one retry β†’ fetch_url called twice, with the same UA.
        self.assertEqual(len(call_log), 2)
        self.assertEqual(call_log[0], call_log[1])
        # The retry delay was awaited once, with the configured value.
        self.assertEqual(sleep_log, [_RETRY_DELAY])
        # And the returned FetchResult is the second (200) one, not the 429.
        self.assertEqual(result.status_code, 200)

    async def test_429_retry_exhausted_counted_as_blocked(self) -> None:
        """Both calls return 429 β†’ caller receives 429 β†’ counted as blocked."""
        async def fake_fetch(url: str, user_agent: str = "", **_kwargs: object) -> FetchResult:
            return _ok_fetch(status_code=429)

        baseline_fetch, baseline_snapshot, baseline_words = _baseline()
        call_count = 0

        async def counting_fetch(url: str, user_agent: str = "", **_kwargs: object) -> FetchResult:
            nonlocal call_count
            call_count += 1
            return _ok_fetch(status_code=429)

        with (
            patch("app.checks.bot_access.fetch_url", new=counting_fetch),
            patch("app.checks.bot_access.asyncio.sleep", new=AsyncMock()),
        ):
            blocked, _, accessible_count = await _probe_bots(
                EXAMPLE_URL,
                _TEST_USER_AGENTS,
                _TEST_MATRIX,
                baseline_fetch=baseline_fetch,
                baseline_snapshot=baseline_snapshot,
                baseline_words=baseline_words,
            )

        # Every probe 429'd, so each one retried once β†’ 2 * 3 = 6 fetch calls.
        self.assertEqual(call_count, 2 * len(_TEST_MATRIX))
        self.assertEqual(accessible_count, 0)
        self.assertEqual(len(blocked), len(_TEST_MATRIX))
        # Each entry follows ``"BotName (HTTP 429)"`` β€” the literal reason
        # emitted by ``_blocked_reason`` when status_code is in {401,403,429,503}.
        for entry in blocked:
            self.assertIn("HTTP 429", entry)

    async def test_429_retry_distinct_from_stagger_delay(self) -> None:
        """The retry sleep uses ``_RETRY_DELAY`` (2.0s) and the inter-probe
        sleep uses ``_STAGGER_DELAY`` (0.5s); they must never be conflated.
        """
        sleep_log: list[float] = []

        async def fake_sleep(seconds: float) -> None:
            sleep_log.append(seconds)

        # Force every probe to 429 so both delays fire on every iteration.
        async def always_429(url: str, user_agent: str = "", **_kwargs: object) -> FetchResult:
            return _ok_fetch(status_code=429)

        baseline_fetch, baseline_snapshot, baseline_words = _baseline()
        with (
            patch("app.checks.bot_access.fetch_url", new=always_429),
            patch("app.checks.bot_access.asyncio.sleep", new=fake_sleep),
        ):
            await _probe_bots(
                EXAMPLE_URL,
                _TEST_USER_AGENTS,
                _TEST_MATRIX,
                baseline_fetch=baseline_fetch,
                baseline_snapshot=baseline_snapshot,
                baseline_words=baseline_words,
            )

        # Per probe: 1 retry sleep at _RETRY_DELAY. Between probes:
        # (matrix_len - 1) stagger sleeps at _STAGGER_DELAY.
        expected = (
            [_RETRY_DELAY] * len(_TEST_MATRIX)        # one retry per probe
            + [_STAGGER_DELAY] * (len(_TEST_MATRIX) - 1)  # N-1 staggers
        )
        # The retry sleeps happen *inside* each probe (before the next stagger),
        # so the actual order is: retry_1, stagger, retry_2, stagger, retry_3.
        # We only need to assert the values and counts here, not the order.
        self.assertEqual(sorted(sleep_log), sorted(expected))
        self.assertEqual(sleep_log.count(_RETRY_DELAY), len(_TEST_MATRIX))
        self.assertEqual(sleep_log.count(_STAGGER_DELAY), len(_TEST_MATRIX) - 1)
        # And the two constants really are distinct, so a regression that
        # accidentally reuses one for the other would show up here too.
        self.assertNotEqual(_STAGGER_DELAY, _RETRY_DELAY)


class CheckBotAccessContractTests(unittest.IsolatedAsyncioTestCase):
    """End-to-end: ``check_bot_access`` still scores correctly under serialization."""

    async def test_passing_matrix_still_scores_6_of_6(self) -> None:
        """Sanity check that the serialization wrapper preserves the existing
        ``pass`` / score=6 outcome on a healthy matrix.
        """
        body = "<html><body>" + ("healthy homepage content for agents. " * 80) + "</body></html>"
        context = make_audit_context(homepage_html=body)

        async def fake_fetch(url: str, user_agent: str = "", **_kwargs: object) -> FetchResult:
            return _ok_fetch(text=body)

        with (
            patch("app.checks.bot_access.fetch_url", new=fake_fetch),
            patch("app.checks.bot_access.asyncio.sleep", new=AsyncMock()),
        ):
            from app.checks.bot_access import check_bot_access

            result = await check_bot_access(context)

        self.assertEqual(result.state, "pass")
        self.assertEqual(result.score, 6)
        self.assertEqual(result.max_score, 6)


if __name__ == "__main__":
    unittest.main()