Viewing files at commit d3edd8c5e361Commit detailsView latest

Browse files
tests/test_agent_loop_hosted.py 223 lines · 10.1 KB · python
1
"""Instrument tests for agent_loop_hosted_v1 (no network, no credentials).
2
3
The counting proxy is exercised against a loopback plaintext HTTP server
4
(``use_tls=False``) so request counting, keep-alive framing, chunked bodies,
5
Host rewriting, header injection and CONNECT tunnels are all checked locally.
6
"""
7
8
from __future__ import annotations
9
10
import http.client
11
import socket
12
import subprocess
13
import sys
14
import tempfile
15
import threading
16
import unittest
17
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18
from pathlib import Path
19
20
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
21
22
import agent_loop_hosted as alh  # noqa: E402
23
from oakbench.httpcount import HttpCountingProxy, RequestRecord, route_shape  # noqa: E402
24
25
26
class _Handler(BaseHTTPRequestHandler):
27
    protocol_version = "HTTP/1.1"
28
    seen_hosts: list = []
29
    seen_injected: list = []
30
31
    def log_message(self, *a):  # noqa: D401
32
        pass
33
34
    def do_GET(self):  # noqa: N802
35
        _Handler.seen_hosts.append(self.headers.get("Host"))
36
        _Handler.seen_injected.append(self.headers.get("X-Bench-Auth"))
37
        if self.path.startswith("/chunked"):
38
            self.send_response(200)
39
            self.send_header("Transfer-Encoding", "chunked")
40
            self.end_headers()
41
            for part in (b"hello ", b"world"):
42
                self.wfile.write(b"%x\r\n%s\r\n" % (len(part), part))
43
            self.wfile.write(b"0\r\n\r\n")
44
            return
45
        body = b"x" * 1000
46
        self.send_response(200)
47
        self.send_header("Content-Length", str(len(body)))
48
        self.end_headers()
49
        self.wfile.write(body)
50
51
    def do_POST(self):  # noqa: N802
52
        n = int(self.headers.get("Content-Length", "0"))
53
        data = self.rfile.read(n)
54
        self.send_response(200)
55
        self.send_header("Content-Length", str(len(data)))
56
        self.end_headers()
57
        self.wfile.write(data)
58
59
60
class HttpCountingProxyTest(unittest.TestCase):
61
    def setUp(self):
62
        _Handler.seen_hosts = []
63
        _Handler.seen_injected = []
64
        self.srv = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
65
        self.port = self.srv.server_address[1]
66
        threading.Thread(target=self.srv.serve_forever, daemon=True).start()
67
        self.proxy = HttpCountingProxy("127.0.0.1", upstream_port=self.port, use_tls=False,
68
                                       inject_headers={"X-Bench-Auth": "secret"}).start()
69
70
    def tearDown(self):
71
        self.proxy.stop()
72
        self.srv.shutdown()
73
74
    def test_counts_keepalive_requests_and_bodies(self):
75
        host, port = self.proxy.address
76
        c = http.client.HTTPConnection(host, port, timeout=5)
77
        c.request("GET", "/api/o/r/branches?token=abc")
78
        self.assertEqual(len(c.getresponse().read()), 1000)
79
        c.request("POST", "/api/o/r/commits/info", body=b"y" * 300)
80
        self.assertEqual(c.getresponse().read(), b"y" * 300)
81
        c.request("GET", "/chunked")
82
        self.assertEqual(c.getresponse().read(), b"hello world")
83
        c.close()
84
        s = self.proxy.snapshot()
85
        self.assertEqual(s.requests, 3)
86
        self.assertEqual(s.connections, 1)
87
        self.assertEqual([r.method for r in s.records], ["GET", "POST", "GET"])
88
        # query strings are never recorded (tokens can live there)
89
        self.assertEqual(s.records[0].path, "/api/o/r/branches")
90
        self.assertGreaterEqual(s.records[1].request_bytes, 300)
91
        self.assertGreater(s.response_bytes, 1300)
92
        # Host rewritten to the upstream; injected header delivered, never recorded
93
        self.assertTrue(all(h.startswith("127.0.0.1:") for h in _Handler.seen_hosts))
94
        self.assertEqual(_Handler.seen_injected, ["secret", "secret"])
95
        self.assertNotIn("secret", repr(s.records))
96
97
    def test_connect_tunnel_counts_bytes_only(self):
98
        host, port = self.proxy.address
99
        sock = socket.create_connection((host, port), timeout=5)
100
        sock.sendall(f"CONNECT 127.0.0.1:{self.port} HTTP/1.1\r\nHost: x\r\n\r\n".encode())
101
        self.assertIn(b"200", sock.recv(1024))
102
        sock.sendall(b"GET /plain HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")
103
        data = b""
104
        while True:
105
            b = sock.recv(65536)
106
            if not b:
107
                break
108
            data += b
109
        sock.close()
110
        self.assertTrue(data.endswith(b"x" * 1000))
111
        import time
112
        time.sleep(0.1)
113
        s = self.proxy.snapshot()
114
        self.assertEqual(s.requests, 0)  # tunnel contents are opaque: no request count
115
        self.assertEqual(len(s.tunnels), 1)
116
        self.assertEqual(s.tunnel_hosts()["127.0.0.1"]["connections"], 1)
117
        self.assertGreater(s.tunnel_bytes, 1000)
118
119
    def test_route_shape_collapses_hashes(self):
120
        self.assertEqual(route_shape("/api/o/r/commits/" + "ab" * 32), "/api/o/r/commits/:hash")
121
        self.assertEqual(route_shape("/api/o/r/runs/12"), "/api/o/r/runs/:n")
122
123
124
class SerialRoundsTest(unittest.TestCase):
125
    def _r(self, s, e):
126
        return RequestRecord("GET", "/", 200, 0, 0, s, 1, e, 1)
127
128
    def test_parallel_requests_share_a_round(self):
129
        self.assertEqual(alh.serial_rounds([self._r(0, 10), self._r(1, 9), self._r(2, 12)]), 1)
130
131
    def test_sequential_requests_each_cost_a_round(self):
132
        self.assertEqual(alh.serial_rounds([self._r(0, 10), self._r(11, 20), self._r(21, 30)]), 3)
133
134
    def test_empty_is_zero_rounds(self):
135
        self.assertEqual(alh.serial_rounds([]), 0)
136
137
138
class GitTraceParseTest(unittest.TestCase):
139
    def test_http2_request_counted_once(self):
140
        # Real libcurl HTTP/2 trace shape: info lines AND a Send-header request
141
        # line for the same request (regression: these were counted twice).
142
        text = (
143
            "== Info: [HTTP/2] [1] [:method: GET]\n"
144
            "== Info: [HTTP/2] [1] [:path: /o/r.git/info/refs?service=git-upload-pack]\n"
145
            "=> Send header: GET /o/r.git/info/refs?service=git-upload-pack HTTP/2\n"
146
            "<= Recv header: HTTP/2 401\n"
147
            "== Info: [HTTP/2] [3] [authorization: Basic <redacted>]\n"
148
            "== Info: [HTTP/2] [3] [:method: GET]\n"
149
            "== Info: [HTTP/2] [3] [:path: /o/r.git/info/refs?service=git-upload-pack]\n"
150
            "=> Send header: GET /o/r.git/info/refs?service=git-upload-pack HTTP/2\n"
151
            "== Info: [HTTP/2] [5] [:method: POST]\n"
152
            "== Info: [HTTP/2] [5] [:path: /o/r.git/git-upload-pack]\n"
153
            "=> Send header: POST /o/r.git/git-upload-pack HTTP/2\n"
154
            "=> Send header: POST /o/r.git/git-receive-pack HTTP/1.1\n"
155
        )
156
        with tempfile.NamedTemporaryFile("w", delete=False) as fh:
157
            fh.write(text)
158
        n, routes = alh.parse_git_trace(fh.name)
159
        self.assertEqual(n, 4)
160
        self.assertEqual(routes, {"GET /info/refs": 2, "POST /git-upload-pack": 1, "POST /git-receive-pack": 1})
161
162
163
class OracleTest(unittest.TestCase):
164
    def test_workspace_digest_matches_git_blob_ids_and_ignores_vcs_dirs(self):
165
        with tempfile.TemporaryDirectory() as d:
166
            root = Path(d)
167
            subprocess.run(["git", "init", "-q", str(root)], check=True)
168
            (root / "a.txt").write_text("alpha\n")
169
            (root / "sub").mkdir()
170
            (root / "sub" / "b.txt").write_text("beta\n")
171
            (root / ".oak").mkdir()
172
            (root / ".oak" / "x").write_text("ignored")
173
            env = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@x", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@x", "PATH": "/usr/bin:/bin:/opt/homebrew/bin"}
174
            subprocess.run(["git", "-C", d, "add", "a.txt", "sub"], check=True)
175
            subprocess.run(["git", "-C", d, "commit", "-q", "-m", "x"], check=True, env=env)
176
            self.assertEqual(alh.workspace_digest(root), alh.fixture_tree(root, "HEAD"))
177
            ok, _ = alh.acquire_oracle(root, root)
178
            self.assertTrue(ok)
179
            (root / "a.txt").write_text("tampered\n")
180
            ok, detail = alh.acquire_oracle(root, root)
181
            self.assertFalse(ok)
182
            self.assertIn("differ=1", detail)
183
184
    def test_task_patch_is_three_files_about_fifty_lines(self):
185
        p = alh.task_patch("t")
186
        self.assertEqual(len(p), 3)
187
        lines = sum(text.count("\n") for _k, text in p.values())
188
        self.assertTrue(45 <= lines <= 55, lines)
189
        self.assertTrue(set(p) <= alh.TASK_PATHS)
190
191
192
class SeedFixtureTest(unittest.TestCase):
193
    def test_seed_is_deterministic_sliced_and_has_merges(self):
194
        import seed_agent_loop_fixture as seed
195
        with tempfile.TemporaryDirectory() as d:
196
            src = Path(d) / "src"
197
            for i in range(230):
198
                p = src / f"dir{i % 7}" / f"f{i}.txt"
199
                p.parent.mkdir(parents=True, exist_ok=True)
200
                p.write_text(f"file {i}\n")
201
            (src / ".github").mkdir()
202
            (src / ".github" / "ci.yml").write_text("excluded\n")
203
            heads = []
204
            for name in ("a", "b"):
205
                g = Path(d) / name
206
                seed.seed_git(src, g, commits=40, seed=7, merge_every=5, files=5)
207
                out = subprocess.run(["git", "-C", str(g), "rev-parse", "main"], capture_output=True, text=True).stdout.strip()
208
                heads.append(out)
209
                count = int(subprocess.run(["git", "-C", str(g), "rev-list", "--count", "main"], capture_output=True, text=True).stdout)
210
                merges = int(subprocess.run(["git", "-C", str(g), "rev-list", "--count", "--merges", "main"], capture_output=True, text=True).stdout)
211
                self.assertEqual(count, 40)
212
                self.assertGreater(merges, 0)
213
                files = subprocess.run(["git", "-C", str(g), "ls-files"], capture_output=True, text=True).stdout.split()
214
                self.assertNotIn(".github/ci.yml", files)
215
                # every commit in the import slices adds <= IMPORT_SLICE files
216
                first = subprocess.run(["git", "-C", str(g), "rev-list", "--reverse", "main"], capture_output=True, text=True).stdout.split()[0]
217
                n0 = len(subprocess.run(["git", "-C", str(g), "ls-tree", "-r", "--name-only", first], capture_output=True, text=True).stdout.split())
218
                self.assertLessEqual(n0, seed.IMPORT_SLICE)
219
            self.assertEqual(heads[0], heads[1])
220
221
222
if __name__ == "__main__":
223
    unittest.main()