pyrtan / tests / test_adapters.py
24,835 bytes · last changed 2026-10-26 in Watches and growing from a gap: trees that run when something speaks, and the branch a gap asks for drafted by rules; release 0.9.0
| 1 | """The other forms of I/O: file, shell, serial, queue, browser; their |
| 2 | allow-lists, their fixtures, and the egress policy over them.""" |
| 3 | |
| 4 | from __future__ import annotations |
| 5 | |
| 6 | import http.server |
| 7 | import json |
| 8 | import os |
| 9 | import socket |
| 10 | import struct |
| 11 | import sys |
| 12 | import threading |
| 13 | import time |
| 14 | from pathlib import Path |
| 15 | |
| 16 | import pytest |
| 17 | |
| 18 | from pyrtan import Registry, RecordedTransport, Runner |
| 19 | from pyrtan.adapters import ( |
| 20 | LocalAdapters, |
| 21 | _mqtt_packet, |
| 22 | _mqtt_read, |
| 23 | _mqtt_str, |
| 24 | adapter_key, |
| 25 | command_allowed, |
| 26 | device_allowed, |
| 27 | path_allowed, |
| 28 | topic_allowed, |
| 29 | ) |
| 30 | from pyrtan.cli import main |
| 31 | from pyrtan.transport import EgressRefused, RecordingTransport, TransportError |
| 32 | from pyrtan.tree import TreeError, check, load_tree_text |
| 33 | |
| 34 | |
| 35 | def registry(tmp_path: Path, **trees: str) -> Registry: |
| 36 | d = tmp_path / "trees" |
| 37 | d.mkdir(exist_ok=True) |
| 38 | for name, text in trees.items(): |
| 39 | (d / f"{name}.toml").write_text(text) |
| 40 | reg = Registry.from_paths([d]) |
| 41 | assert not reg.errors, reg.errors |
| 42 | return reg |
| 43 | |
| 44 | |
| 45 | # ---------------------------------------------------------------- allow-lists |
| 46 | |
| 47 | |
| 48 | def test_allow_lists(): |
| 49 | assert path_allowed(["/var/log/*"], "/var/log/syslog") and path_allowed(["/var/log"], "/var/log/nginx/access.log") |
| 50 | assert not path_allowed(["/var/log"], "/var/logs") and not path_allowed(["/var/log/*.log"], "/etc/passwd") and not path_allowed([], "/x") |
| 51 | assert path_allowed(["*"], "/anything") and path_allowed(["~/notes"], os.path.expanduser("~/notes/today.md")) |
| 52 | assert command_allowed(["df", "uptime"], "df") and command_allowed(["df"], "/usr/bin/df") and command_allowed(["/usr/bin/df"], "/usr/bin/df") |
| 53 | assert not command_allowed(["df"], "dfx") and not command_allowed(["/usr/bin/df"], "/bin/df") and command_allowed(["*"], "rm") |
| 54 | assert device_allowed(["/dev/ttyUSB*"], "/dev/ttyUSB0") and not device_allowed(["/dev/ttyUSB*"], "/dev/ttyACM0") |
| 55 | assert topic_allowed(["home/+/temperature"], "home/kitchen/temperature") and not topic_allowed(["home/+/temperature"], "home/kitchen/humidity") |
| 56 | assert topic_allowed(["home/#"], "home/a/b/c") and topic_allowed(["home/#"], "home") and not topic_allowed(["home/#"], "work/a") |
| 57 | assert topic_allowed(["home/*"], "home/a") and not topic_allowed(["home/*"], "home/a/b") and topic_allowed(["#"], "x/y") and not topic_allowed(["home/a"], "home/a/b") |
| 58 | |
| 59 | |
| 60 | def test_adapter_keys_are_stable(): |
| 61 | assert adapter_key("file", {"path": "/var/log/syslog", "tail": 3}) == "file /var/log/syslog" |
| 62 | assert adapter_key("file", {"path": "/tmp/x", "append": "hi"}) == "file append /tmp/x" |
| 63 | assert adapter_key("shell", {"command": "df", "args": ["-P", "/", 2]}) == "shell df -P / 2" |
| 64 | assert adapter_key("serial", {"device": "/dev/ttyUSB0", "write": "AT\r\n"}) == 'serial /dev/ttyUSB0 "AT\\r\\n"' |
| 65 | assert adapter_key("queue", {"url": "mqtt://b", "topic": "t", "publish": "on"}) == "queue publish mqtt://b t" |
| 66 | assert adapter_key("browser", {"url": "https://x/"}) == "browser https://x/" |
| 67 | |
| 68 | |
| 69 | # ---------------------------------------------------------------- file |
| 70 | |
| 71 | |
| 72 | FILE_TREE = """ |
| 73 | name = "notes" |
| 74 | description = "reads and appends a notes file" |
| 75 | paths = ["{root}"] |
| 76 | start = "read" |
| 77 | |
| 78 | [inputs.path] |
| 79 | type = "str" |
| 80 | |
| 81 | [nodes.read] |
| 82 | kind = "observe" |
| 83 | file = {{ path = "{{{{ inputs.path }}}}", tail = 2 }} |
| 84 | into = "f" |
| 85 | extract = [{{ into = "last", regex = "(?m)^(.*)$", group = 1, all = true }}] |
| 86 | next = "exists" |
| 87 | |
| 88 | [nodes.exists] |
| 89 | kind = "decide" |
| 90 | on = "f.exists" |
| 91 | branches = [{{ when = {{ eq = true }}, then = "note" }}, {{ else = "none" }}] |
| 92 | |
| 93 | [nodes.note] |
| 94 | kind = "act" |
| 95 | file = {{ path = "{{{{ inputs.path }}}}", append = "visited at {{{{ now.iso }}}}\\n" }} |
| 96 | into = "w" |
| 97 | next = "say" |
| 98 | |
| 99 | [nodes.say] |
| 100 | kind = "done" |
| 101 | say = "{{{{ state.f.lines | length }}}} line(s), {{{{ state.f.size }}}} B; wrote {{{{ state.w.written }}}} B" |
| 102 | |
| 103 | [nodes.none] |
| 104 | kind = "fail" |
| 105 | reason = "no-file" |
| 106 | say = "{{{{ inputs.path }}}} is not there" |
| 107 | """ |
| 108 | |
| 109 | |
| 110 | def test_file_read_tail_and_append(tmp_path): |
| 111 | reg = registry(tmp_path, notes=FILE_TREE.format(root=str(tmp_path / "data"))) |
| 112 | (tmp_path / "data").mkdir() |
| 113 | f = tmp_path / "data" / "notes.txt" |
| 114 | f.write_text("one\ntwo\nthree\n") |
| 115 | out = Runner(reg).run_tree("notes", {"path": str(f)}) |
| 116 | assert out.status == "done", out.say |
| 117 | assert out.say.startswith("2 line(s), 14 B; wrote ") |
| 118 | assert f.read_text().startswith("one\ntwo\nthree\nvisited at 20") |
| 119 | assert out.trace.steps[0].summary == f"file {f} -> 14 B; last=['two', 'three']" |
| 120 | assert out.trace.steps[2].summary.startswith(f"file {f} -> wrote ") |
| 121 | out = Runner(reg).run_tree("notes", {"path": str(tmp_path / "data" / "missing.txt")}) |
| 122 | assert out.status == "failed" and out.reason == "no-file" and "missing" in out.trace.steps[0].summary |
| 123 | # Outside the tree's paths: refused at run time, by the runtime, before the adapter. |
| 124 | out = Runner(reg).run_tree("notes", {"path": "/etc/hostname"}) |
| 125 | assert out.status == "error" and out.reason == "path-not-allowed" and "outside this tree's paths" in out.say |
| 126 | # And ".." does not escape. |
| 127 | out = Runner(reg).run_tree("notes", {"path": str(tmp_path / "data" / ".." / "secret")}) |
| 128 | assert out.reason == "path-not-allowed" |
| 129 | # A whole file, and JSON parsed. |
| 130 | reg2 = registry(tmp_path, j=''' |
| 131 | name = "j" |
| 132 | paths = ["*"] |
| 133 | start = "r" |
| 134 | [nodes.r] |
| 135 | kind = "observe" |
| 136 | file = { path = "%s" } |
| 137 | into = "f" |
| 138 | next = "d" |
| 139 | [nodes.d] |
| 140 | kind = "done" |
| 141 | say = "{{ state.f.json.a }}" |
| 142 | ''' % (tmp_path / "x.json")) |
| 143 | (tmp_path / "x.json").write_text('{"a": 42}') |
| 144 | assert Runner(reg2).run_tree("j").say == "42" |
| 145 | |
| 146 | |
| 147 | def test_file_checker(): |
| 148 | base = ''' |
| 149 | name = "f" |
| 150 | paths = ["/var/log"] |
| 151 | start = "r" |
| 152 | [nodes.r] |
| 153 | kind = "observe" |
| 154 | file = { path = "/var/log/syslog" } |
| 155 | next = "d" |
| 156 | [nodes.d] |
| 157 | kind = "done" |
| 158 | ''' |
| 159 | tree = load_tree_text(base) |
| 160 | assert tree.sources() == ["file"] and tree.capability()["paths"] == ["/var/log"] and tree.capability()["sources"] == ["file"] |
| 161 | with pytest.raises(TreeError, match="make this an act"): |
| 162 | load_tree_text(base.replace('path = "/var/log/syslog"', 'path = "/var/log/syslog", append = "x"')) |
| 163 | with pytest.raises(TreeError, match="not under this tree's paths"): |
| 164 | load_tree_text(base.replace("/var/log/syslog", "/etc/passwd")) |
| 165 | with pytest.raises(TreeError, match="must list the paths"): |
| 166 | load_tree_text(base.replace('paths = ["/var/log"]', "")) |
| 167 | with pytest.raises(TreeError, match="exactly one of"): |
| 168 | load_tree_text(base.replace('file = { path = "/var/log/syslog" }', 'file = { path = "/var/log/syslog" }\nshell = { command = "df" }')) |
| 169 | notes = [str(p) for p in check(load_tree_text(base.replace('paths = ["/var/log"]', 'paths = ["/var/log"]\ncommands = ["df"]')))] |
| 170 | assert any("commands are listed but no node uses them" in n for n in notes) |
| 171 | notes = [str(p) for p in check(load_tree_text(base.replace("/var/log/syslog", "/var/log/{{ inputs.name }}").replace('start = "r"', 'start = "r"\n[inputs.name]\ntype = "str"')))] |
| 172 | assert any("decided at run time" in n for n in notes) |
| 173 | |
| 174 | |
| 175 | # ---------------------------------------------------------------- shell |
| 176 | |
| 177 | |
| 178 | SHELL_TREE = """ |
| 179 | name = "say" |
| 180 | description = "runs a program" |
| 181 | commands = ["printf", "{py}", "false", "sleep"] |
| 182 | start = "run" |
| 183 | |
| 184 | [inputs.text] |
| 185 | type = "str" |
| 186 | default = "hi" |
| 187 | |
| 188 | [nodes.run] |
| 189 | kind = "observe" |
| 190 | shell = {{ command = "printf", args = ["%s|%s", "{{{{ inputs.text }}}}", "$HOME;ls"] }} |
| 191 | into = "p" |
| 192 | retries = 0 |
| 193 | next = "ok" |
| 194 | |
| 195 | [nodes.ok] |
| 196 | kind = "decide" |
| 197 | on = "p.ok" |
| 198 | branches = [{{ when = {{ eq = true }}, then = "say" }}, {{ else = "bad" }}] |
| 199 | |
| 200 | [nodes.say] |
| 201 | kind = "done" |
| 202 | say = "{{{{ state.p.stdout }}}}" |
| 203 | |
| 204 | [nodes.bad] |
| 205 | kind = "fail" |
| 206 | reason = "exit" |
| 207 | say = "exit {{{{ state.p.status }}}}: {{{{ state.p.stderr | strip }}}}" |
| 208 | """ |
| 209 | |
| 210 | |
| 211 | def test_shell_runs_one_program_without_a_shell(tmp_path): |
| 212 | reg = registry(tmp_path, say=SHELL_TREE.format(py=sys.executable)) |
| 213 | out = Runner(reg).run_tree("say", {"text": "a b"}) |
| 214 | assert out.status == "done" and out.say == "a b|$HOME;ls" # the arguments reached printf as given: no expansion, no second command |
| 215 | assert out.trace.steps[0].summary == "shell printf %s|%s a b $HOME;ls -> exit 0" |
| 216 | # Exit status is observed, not an error; stderr too; stdout as JSON when it is. |
| 217 | reg2 = registry(tmp_path, say=SHELL_TREE.format(py=sys.executable).replace('shell = { command = "printf", args = ["%s|%s", "{{ inputs.text }}", "$HOME;ls"] }', 'shell = { command = "%s", args = ["-c", "import sys; print(\'{\\"n\\": 3}\'); sys.stderr.write(\'careful\'); sys.exit(2)"] }' % sys.executable).replace('say = "{{ state.p.stdout }}"', 'say = "{{ state.p.json.n }}"')) |
| 218 | out = Runner(reg2).run_tree("say") |
| 219 | assert out.status == "failed" and out.say == "exit 2: careful" |
| 220 | # A program not on the box, or too slow, is a transport error (an observe retries; here retries = 0). |
| 221 | reg3 = registry(tmp_path, say=SHELL_TREE.format(py=sys.executable).replace('command = "printf"', 'command = "{{ inputs.text }}"')) |
| 222 | out = Runner(reg3).run_tree("say", {"text": "no-such-program-xyz"}) |
| 223 | assert out.status == "error" and out.reason == "command-not-allowed" |
| 224 | out = Runner(reg3).run_tree("say", {"text": "sleep"}) # sleep "%s|%s" ... exits non-zero: fine, that is observed |
| 225 | assert out.status == "failed" |
| 226 | reg4 = registry(tmp_path, say=SHELL_TREE.format(py=sys.executable).replace('shell = { command = "printf", args = ["%s|%s", "{{ inputs.text }}", "$HOME;ls"] }', 'shell = { command = "sleep", args = ["5"] }\ntimeout = 0.3')) |
| 227 | t0 = time.time() |
| 228 | out = Runner(reg4).run_tree("say") |
| 229 | assert out.status == "error" and out.reason == "transport" and "no answer within" in out.say and time.time() - t0 < 3 |
| 230 | with pytest.raises(TreeError, match="not in this tree's commands"): |
| 231 | load_tree_text(SHELL_TREE.format(py=sys.executable).replace('command = "printf"', 'command = "rm"')) |
| 232 | with pytest.raises(TreeError, match="must list the commands"): |
| 233 | load_tree_text(SHELL_TREE.format(py=sys.executable).replace('commands = ["printf", "%s", "false", "sleep"]' % sys.executable, "")) |
| 234 | |
| 235 | |
| 236 | # ---------------------------------------------------------------- serial |
| 237 | |
| 238 | |
| 239 | SERIAL_TREE = """ |
| 240 | name = "modem" |
| 241 | description = "asks a modem whether it is there" |
| 242 | devices = ["/dev/pts/*", "/dev/ttyUSB*"] |
| 243 | start = "at" |
| 244 | |
| 245 | [inputs.device] |
| 246 | type = "str" |
| 247 | |
| 248 | [nodes.at] |
| 249 | kind = "observe" |
| 250 | serial = { device = "{{ inputs.device }}", baud = 9600, write = "AT\\r\\n", read_until = "\\n" } |
| 251 | into = "s" |
| 252 | timeout = 2 |
| 253 | retries = 0 |
| 254 | extract = [{ into = "answer", regex = "(OK|ERROR)" }] |
| 255 | next = "ok" |
| 256 | |
| 257 | [nodes.ok] |
| 258 | kind = "decide" |
| 259 | on = "answer" |
| 260 | branches = [{ when = { eq = "OK" }, then = "there" }, { else = "odd" }] |
| 261 | |
| 262 | [nodes.there] |
| 263 | kind = "done" |
| 264 | say = "{{ inputs.device }} answers ({{ state.s.bytes }} B, sent {{ state.s.sent }})" |
| 265 | |
| 266 | [nodes.odd] |
| 267 | kind = "fail" |
| 268 | reason = "odd-answer" |
| 269 | say = "{{ state.s.body | strip }}" |
| 270 | """ |
| 271 | |
| 272 | |
| 273 | @pytest.mark.skipif(not hasattr(os, "openpty"), reason="no pty on this platform") |
| 274 | def test_serial_exchange_over_a_pty(tmp_path): |
| 275 | reg = registry(tmp_path, modem=SERIAL_TREE) |
| 276 | master, slave = os.openpty() |
| 277 | device = os.ttyname(slave) |
| 278 | got: list[bytes] = [] |
| 279 | |
| 280 | def modem(): |
| 281 | buf = b"" |
| 282 | while b"\n" not in buf: |
| 283 | buf += os.read(master, 64) |
| 284 | got.append(buf) |
| 285 | os.write(master, b"OK\r\n") |
| 286 | |
| 287 | t = threading.Thread(target=modem, daemon=True) |
| 288 | t.start() |
| 289 | try: |
| 290 | out = Runner(reg).run_tree("modem", {"device": device}) |
| 291 | t.join(2) |
| 292 | assert out.status == "done", out.say |
| 293 | assert out.say == f"{device} answers (4 B, sent 4)" and got == [b"AT\r\n"] |
| 294 | assert out.trace.steps[0].summary == f"serial {device} -> 4 B; answer=OK" |
| 295 | # Nothing answers: a transport error, not a hang. |
| 296 | t0 = time.time() |
| 297 | out = Runner(reg).run_tree("modem", {"device": device}) |
| 298 | assert out.status == "error" and out.reason == "transport" and "no answer within" in out.say and time.time() - t0 < 5 |
| 299 | finally: |
| 300 | os.close(master) |
| 301 | os.close(slave) |
| 302 | assert Runner(reg).run_tree("modem", {"device": "/dev/ttyACM0"}).reason == "device-not-allowed" |
| 303 | out = Runner(reg).run_tree("modem", {"device": "/dev/ttyUSB99"}) |
| 304 | assert out.status == "error" and out.reason == "transport" |
| 305 | with pytest.raises(TreeError, match="not in this tree's devices"): |
| 306 | load_tree_text(SERIAL_TREE.replace('device = "{{ inputs.device }}"', 'device = "/dev/ttyACM0"')) |
| 307 | |
| 308 | |
| 309 | # ---------------------------------------------------------------- queue (MQTT) |
| 310 | |
| 311 | |
| 312 | class Broker: |
| 313 | """Just enough of an MQTT 3.1.1 broker: one client at a time, a canned |
| 314 | message for whoever subscribes, a record of what is published.""" |
| 315 | |
| 316 | def __init__(self, message: bytes | None = b'{"temperature": 23.5}', refuse: int = 0): |
| 317 | self.message = message |
| 318 | self.refuse = refuse |
| 319 | self.published: list[tuple[str, bytes]] = [] |
| 320 | self.subscribed: list[str] = [] |
| 321 | self.subscribers: list[tuple[socket.socket, str]] = [] |
| 322 | self.sock = socket.socket() |
| 323 | self.sock.bind(("127.0.0.1", 0)) |
| 324 | self.sock.listen(5) |
| 325 | self.port = self.sock.getsockname()[1] |
| 326 | self.url = f"mqtt://127.0.0.1:{self.port}" |
| 327 | threading.Thread(target=self._serve, daemon=True).start() |
| 328 | |
| 329 | def _serve(self): |
| 330 | while True: |
| 331 | try: |
| 332 | conn, _ = self.sock.accept() |
| 333 | except OSError: |
| 334 | return |
| 335 | threading.Thread(target=self._client, args=(conn,), daemon=True).start() |
| 336 | |
| 337 | def _client(self, conn: socket.socket): |
| 338 | deadline = time.monotonic() + 10 |
| 339 | try: |
| 340 | while True: |
| 341 | pkt = _mqtt_read(conn, deadline) |
| 342 | if pkt is None: |
| 343 | return |
| 344 | ptype, flags, body = pkt |
| 345 | if ptype == 1: |
| 346 | conn.sendall(_mqtt_packet(0x20, bytes([0, self.refuse]))) |
| 347 | if self.refuse: |
| 348 | return |
| 349 | elif ptype == 8: |
| 350 | pid = body[:2] |
| 351 | tlen = struct.unpack(">H", body[2:4])[0] |
| 352 | topic = body[4:4 + tlen].decode() |
| 353 | self.subscribed.append(topic) |
| 354 | self.subscribers.append((conn, topic)) |
| 355 | conn.sendall(_mqtt_packet(0x90, pid + b"\x00")) |
| 356 | if self.message is not None: |
| 357 | conn.sendall(_mqtt_packet(0x30, _mqtt_str(topic.replace("+", "kitchen")) + self.message)) |
| 358 | elif ptype == 12: # PINGREQ |
| 359 | conn.sendall(_mqtt_packet(0xD0, b"")) |
| 360 | elif ptype == 3: |
| 361 | tlen = struct.unpack(">H", body[:2])[0] |
| 362 | self.published.append((body[2:2 + tlen].decode(), body[2 + tlen:])) |
| 363 | elif ptype == 14: |
| 364 | return |
| 365 | finally: |
| 366 | conn.close() |
| 367 | |
| 368 | def publish_to_subscribers(self, topic: str, payload: bytes) -> None: |
| 369 | for conn, _ in list(self.subscribers): |
| 370 | try: |
| 371 | conn.sendall(_mqtt_packet(0x30, _mqtt_str(topic) + payload)) |
| 372 | except OSError: |
| 373 | pass |
| 374 | |
| 375 | def close(self): |
| 376 | self.sock.close() |
| 377 | |
| 378 | |
| 379 | QUEUE_TREE = """ |
| 380 | name = "sensor" |
| 381 | description = "the last temperature a room reported" |
| 382 | hosts = ["127.0.0.1"] |
| 383 | topics = ["home/+/temperature", "home/+/light"] |
| 384 | start = "wait" |
| 385 | |
| 386 | [inputs.room] |
| 387 | type = "str" |
| 388 | default = "kitchen" |
| 389 | |
| 390 | [inputs.broker] |
| 391 | type = "str" |
| 392 | |
| 393 | [nodes.wait] |
| 394 | kind = "observe" |
| 395 | queue = { url = "{{ inputs.broker }}", topic = "home/{{ inputs.room }}/temperature" } |
| 396 | into = "q" |
| 397 | timeout = 2 |
| 398 | retries = 0 |
| 399 | next = "got" |
| 400 | |
| 401 | [nodes.got] |
| 402 | kind = "decide" |
| 403 | on = "q.received" |
| 404 | branches = [{ when = { eq = true }, then = "warm" }, { else = "quiet" }] |
| 405 | |
| 406 | [nodes.warm] |
| 407 | kind = "decide" |
| 408 | on = "q.json.temperature" |
| 409 | branches = [{ when = { gt = 25 }, then = "cool-it" }, { else = "fine" }] |
| 410 | |
| 411 | [nodes.cool-it] |
| 412 | kind = "act" |
| 413 | queue = { url = "{{ inputs.broker }}", topic = "home/{{ inputs.room }}/light", publish = { on = false } } |
| 414 | into = "sent" |
| 415 | next = "fine" |
| 416 | |
| 417 | [nodes.fine] |
| 418 | kind = "done" |
| 419 | say = "{{ inputs.room }}: {{ state.q.json.temperature }} from {{ state.q.message.topic }}" |
| 420 | |
| 421 | [nodes.quiet] |
| 422 | kind = "fail" |
| 423 | reason = "no-reading" |
| 424 | say = "nothing on home/{{ inputs.room }}/temperature within 2 s" |
| 425 | """ |
| 426 | |
| 427 | |
| 428 | def test_queue_subscribe_wait_and_publish(tmp_path): |
| 429 | reg = registry(tmp_path, sensor=QUEUE_TREE) |
| 430 | b = Broker() |
| 431 | try: |
| 432 | out = Runner(reg).run_tree("sensor", {"broker": b.url}) |
| 433 | assert out.status == "done", out.say |
| 434 | assert out.say == "kitchen: 23.5 from home/kitchen/temperature" and b.subscribed == ["home/kitchen/temperature"] and b.published == [] |
| 435 | assert out.trace.steps[0].summary == f"queue home/kitchen/temperature at {b.url} -> 1 message" |
| 436 | hot = Broker(message=b'{"temperature": 31}') |
| 437 | out = Runner(reg).run_tree("sensor", {"broker": hot.url}) |
| 438 | for _ in range(50): # the broker reads the publish on its own thread |
| 439 | if hot.published: |
| 440 | break |
| 441 | time.sleep(0.02) |
| 442 | assert out.status == "done" and hot.published == [("home/kitchen/light", b'{"on": false}')] |
| 443 | assert out.trace.steps[3].summary == f"queue home/kitchen/light at {hot.url} -> published 13 B" |
| 444 | hot.close() |
| 445 | quiet = Broker(message=None) |
| 446 | t0 = time.time() |
| 447 | out = Runner(reg).run_tree("sensor", {"broker": quiet.url}) |
| 448 | assert out.status == "failed" and out.reason == "no-reading" and time.time() - t0 < 5 |
| 449 | quiet.close() |
| 450 | refusing = Broker(refuse=5) |
| 451 | out = Runner(reg).run_tree("sensor", {"broker": refusing.url}) |
| 452 | assert out.status == "error" and "refused (code 5)" in out.say |
| 453 | refusing.close() |
| 454 | # Allow-lists: the broker's host in hosts, the topic in topics. |
| 455 | assert Runner(reg).run_tree("sensor", {"broker": "mqtt://broker.example"}).reason == "host-not-allowed" |
| 456 | assert Runner(reg).run_tree("sensor", {"broker": b.url, "room": "a/b"}).reason == "topic-not-allowed" |
| 457 | assert Runner(reg).run_tree("sensor", {"broker": "http://127.0.0.1/"}).reason == "host-not-allowed" |
| 458 | finally: |
| 459 | b.close() |
| 460 | with pytest.raises(TreeError, match="make this an act"): |
| 461 | load_tree_text(QUEUE_TREE.replace('topic = "home/{{ inputs.room }}/temperature" }', 'topic = "home/{{ inputs.room }}/temperature", publish = "x" }')) |
| 462 | with pytest.raises(TreeError, match="must list the topics"): |
| 463 | load_tree_text(QUEUE_TREE.replace('topics = ["home/+/temperature", "home/+/light"]', "")) |
| 464 | with pytest.raises(TreeError, match="not under this tree's topics"): |
| 465 | load_tree_text(QUEUE_TREE.replace('topic = "home/{{ inputs.room }}/light"', 'topic = "work/light"')) |
| 466 | |
| 467 | |
| 468 | # ---------------------------------------------------------------- egress, fixtures, the command |
| 469 | |
| 470 | |
| 471 | def test_egress_public_keeps_the_box_closed(tmp_path): |
| 472 | hosted = LocalAdapters(egress="public") |
| 473 | for source, spec in (("file", {"path": "/etc/hostname"}), ("shell", {"command": "uptime"}), ("serial", {"device": "/dev/ttyUSB0"})): |
| 474 | with pytest.raises(EgressRefused, match="its own box"): |
| 475 | hosted.perform(source, spec, 1.0) |
| 476 | with pytest.raises(EgressRefused): |
| 477 | hosted.perform("queue", {"url": "mqtt://127.0.0.1", "topic": "t"}, 1.0) |
| 478 | with pytest.raises(EgressRefused): |
| 479 | hosted.perform("browser", {"url": "http://10.0.0.1/"}, 1.0) |
| 480 | # Through a tree: the refusal is an error the trace shows, and an observe does not retry it. |
| 481 | reg = registry(tmp_path, say=SHELL_TREE.format(py=sys.executable)) |
| 482 | out = Runner(reg, adapters=hosted).run_tree("say") |
| 483 | assert out.status == "error" and out.reason == "transport" and "its own box" in out.say |
| 484 | |
| 485 | |
| 486 | def test_fixtures_record_and_replay_every_source(tmp_path, capsys): |
| 487 | reg = registry(tmp_path, say=SHELL_TREE.format(py=sys.executable), notes=FILE_TREE.format(root=str(tmp_path))) |
| 488 | (tmp_path / "n.txt").write_text("a\nb\n") |
| 489 | rec = RecordingTransport(None, LocalAdapters()) # type: ignore[arg-type] |
| 490 | runner = Runner(reg, rec) |
| 491 | assert runner.run_tree("say", {"text": "x"}).status == "done" |
| 492 | assert runner.run_tree("notes", {"path": str(tmp_path / "n.txt")}).status == "done" |
| 493 | assert set(rec.recorded) == {"shell printf %s|%s x $HOME;ls", f"file {tmp_path / 'n.txt'}", f"file append {tmp_path / 'n.txt'}"} |
| 494 | rec.save(tmp_path / "fx.json") |
| 495 | replay = RecordedTransport.from_file(tmp_path / "fx.json") |
| 496 | out = Runner(reg, replay).run_tree("say", {"text": "x"}) |
| 497 | assert out.status == "done" and out.say == "x|$HOME;ls" and replay.calls == [("shell", "shell printf %s|%s x $HOME;ls")] |
| 498 | out = Runner(reg, replay).run_tree("say", {"text": "unrecorded"}) |
| 499 | assert out.status == "error" and "no recorded result for shell printf %s|%s unrecorded" in out.say |
| 500 | # The command records and replays the same way. |
| 501 | code = main(["run", "--tree", "say", "--input", "text=cli", "--trees", str(tmp_path / "trees"), "--record", str(tmp_path / "cli.json"), "--json", "--no-trace"]) |
| 502 | assert code == 0 and json.loads(capsys.readouterr().out)["say"] == "cli|$HOME;ls" |
| 503 | assert "shell printf %s|%s cli $HOME;ls" in json.loads((tmp_path / "cli.json").read_text()) |
| 504 | code = main(["run", "--tree", "say", "--input", "text=cli", "--trees", str(tmp_path / "trees"), "--replay", str(tmp_path / "cli.json"), "--json", "--no-trace"]) |
| 505 | assert code == 0 and json.loads(capsys.readouterr().out)["say"] == "cli|$HOME;ls" |
| 506 | # A service with --egress public refuses the box; a recorded transport answers for the sources too. |
| 507 | from pyrtan.service import Service |
| 508 | |
| 509 | svc = Service(reg, egress="public") |
| 510 | assert svc.run({"tree": "say", "inputs": {}})["reason"] == "transport" |
| 511 | svc = Service(reg, transport=replay) |
| 512 | assert svc.run({"tree": "say", "inputs": {"text": "x"}})["status"] == "done" |
| 513 | |
| 514 | |
| 515 | # ---------------------------------------------------------------- browser |
| 516 | |
| 517 | |
| 518 | PAGE = b"""<!doctype html><html><head><title>App</title></head><body> |
| 519 | <div id="root">loading</div><a id="away" href="%s">away</a> |
| 520 | <script>setTimeout(() => { document.getElementById("root").innerHTML = "<h1>Rendered</h1><button id=more>more</button>"; |
| 521 | document.getElementById("more").onclick = () => { document.getElementById("root").insertAdjacentHTML("beforeend", "<p class=extra>extra</p>"); }; }, 50);</script> |
| 522 | </body></html>""" |
| 523 | |
| 524 | BROWSER_TREE = """ |
| 525 | name = "app" |
| 526 | description = "a page that needs a script to render" |
| 527 | hosts = ["127.0.0.1"] |
| 528 | start = "open" |
| 529 | |
| 530 | [inputs.url] |
| 531 | type = "str" |
| 532 | |
| 533 | [nodes.open] |
| 534 | kind = "observe" |
| 535 | browser = { url = "{{ inputs.url }}", wait_for = "h1", actions = [{ click = "#more" }, { wait_for = ".extra" }] } |
| 536 | into = "page" |
| 537 | timeout = 15 |
| 538 | retries = 0 |
| 539 | extract = [{ into = "heading", css = "h1" }, { into = "extra", css = "p.extra" }] |
| 540 | next = "say" |
| 541 | |
| 542 | [nodes.say] |
| 543 | kind = "done" |
| 544 | say = "{{ state.page.title }}: {{ state.heading }} / {{ state.extra }} ({{ state.page.status }})" |
| 545 | """ |
| 546 | |
| 547 | |
| 548 | @pytest.fixture |
| 549 | def pages(): |
| 550 | class H(http.server.BaseHTTPRequestHandler): |
| 551 | def do_GET(self): |
| 552 | self.send_response(200) |
| 553 | self.send_header("Content-Type", "text/html") |
| 554 | self.end_headers() |
| 555 | self.wfile.write(PAGE % f"http://localhost:{self.server.server_port}/elsewhere".encode()) |
| 556 | |
| 557 | def log_message(self, *a): |
| 558 | pass |
| 559 | |
| 560 | srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H) |
| 561 | threading.Thread(target=srv.serve_forever, daemon=True).start() |
| 562 | yield f"http://127.0.0.1:{srv.server_port}" |
| 563 | srv.shutdown() |
| 564 | |
| 565 | |
| 566 | def test_browser_renders_acts_and_stays_on_its_hosts(tmp_path, pages): |
| 567 | pytest.importorskip("playwright") |
| 568 | adapters = LocalAdapters() |
| 569 | try: |
| 570 | adapters.perform("browser", {"url": pages + "/"}, 15.0) |
| 571 | except TransportError as e: |
| 572 | pytest.skip(str(e)) |
| 573 | reg = registry(tmp_path, app=BROWSER_TREE) |
| 574 | try: |
| 575 | out = Runner(reg, adapters=adapters).run_tree("app", {"url": pages + "/app"}) |
| 576 | assert out.status == "done", out.say |
| 577 | assert out.say == "App: Rendered / extra (200)" |
| 578 | assert out.trace.steps[0].summary == f"browser {pages}/app -> 200; heading=Rendered, extra=extra" |
| 579 | # A click that would take the page to another host is refused; so is a URL outside hosts, before any browser starts. |
| 580 | reg2 = registry(tmp_path, app=BROWSER_TREE.replace("{ click = \"#more\" }", "{ click = \"#away\" }")) |
| 581 | out = Runner(reg2, adapters=adapters).run_tree("app", {"url": pages + "/app"}) |
| 582 | assert out.status == "error" and out.reason == "transport" and "outside this tree's hosts" in out.say |
| 583 | out = Runner(reg, adapters=adapters).run_tree("app", {"url": "http://localhost:1/"}) |
| 584 | assert out.reason == "host-not-allowed" |
| 585 | finally: |
| 586 | adapters.close() |
| 587 | with pytest.raises(TreeError, match="exactly one of click"): |
| 588 | load_tree_text(BROWSER_TREE.replace('{ click = "#more" }', '{ tap = "#more" }')) |
| 589 | with pytest.raises(TreeError, match="not in this tree's hosts"): |
| 590 | load_tree_text(BROWSER_TREE.replace('url = "{{ inputs.url }}"', 'url = "https://example.com/"')) |