pyrtan / tests / test_federation.py
58,937 bytes · last changed 2026-10-27 in No seeds: pyrtan.com is GitHub Pages and a hub list, nothing more
| 1 | """Two hubs, each with its own service, following each other over real |
| 2 | sockets: who may ask whom, what is announced, and what is refused.""" |
| 3 | |
| 4 | import json |
| 5 | import threading |
| 6 | import time |
| 7 | |
| 8 | import pytest |
| 9 | |
| 10 | from pyrtan import Registry, RecordedTransport |
| 11 | from pyrtan.cli import main |
| 12 | from pyrtan.federation import Federation, Remote, sign, verify |
| 13 | from pyrtan.hub import Hub |
| 14 | from pyrtan.service import Server, Service, http_json |
| 15 | from conftest import EXAMPLES, FIXTURES, ROOT |
| 16 | |
| 17 | ECHO = """ |
| 18 | name = "{name}" |
| 19 | description = "echoes" |
| 20 | start = "say" |
| 21 | |
| 22 | [[triggers]] |
| 23 | pattern = "{word} {{text}}" |
| 24 | |
| 25 | [inputs.text] |
| 26 | type = "str" |
| 27 | |
| 28 | [nodes.say] |
| 29 | kind = "done" |
| 30 | say = "{name}: {{{{ inputs.text }}}}" |
| 31 | output = {{ text = "{{{{ inputs.text }}}}" }} |
| 32 | """ |
| 33 | |
| 34 | |
| 35 | class Swarm: |
| 36 | """A hub with a token, federated, holding one service with the given trees.""" |
| 37 | |
| 38 | def __init__(self, name: str, tree_dir, token: str | None = None, **fed): |
| 39 | self.name = name |
| 40 | self.token = token |
| 41 | self.hub = Hub(name=name, poll_every=60, token=token) |
| 42 | self.hub_srv = Server(self.hub.route, quiet=True, token=token, federated=True).start() |
| 43 | self.fed = self.hub.federate(self.hub_srv.url, **fed) |
| 44 | self.svc = Service(Registry.from_paths([tree_dir]), name=f"{name}-svc", transport=RecordedTransport.from_file(FIXTURES / "examples.json"), token=token, hub=self.hub_srv.url) |
| 45 | self.svc_srv = Server(self.svc.route, quiet=True, token=token).start() |
| 46 | assert self.svc.register(self.svc_srv.url) |
| 47 | |
| 48 | @property |
| 49 | def url(self): |
| 50 | return self.hub_srv.url |
| 51 | |
| 52 | def get(self, path): |
| 53 | return http_json("GET", f"{self.url}{path}", token=self.token) |
| 54 | |
| 55 | def post(self, path, body): |
| 56 | return http_json("POST", f"{self.url}{path}", body, token=self.token) |
| 57 | |
| 58 | def stop(self): |
| 59 | self.hub.stop() |
| 60 | self.hub_srv.stop() |
| 61 | self.svc_srv.stop() |
| 62 | |
| 63 | |
| 64 | @pytest.fixture |
| 65 | def two(tmp_path): |
| 66 | (tmp_path / "a").mkdir() |
| 67 | (tmp_path / "b").mkdir() |
| 68 | (tmp_path / "a" / "alpha.toml").write_text(ECHO.format(name="alpha", word="alpha")) |
| 69 | (tmp_path / "b" / "beta.toml").write_text(ECHO.format(name="beta", word="beta")) |
| 70 | a = Swarm("a", tmp_path / "a", token="token-a") |
| 71 | b = Swarm("b", tmp_path / "b", token="token-b") |
| 72 | yield a, b |
| 73 | a.stop() |
| 74 | b.stop() |
| 75 | |
| 76 | |
| 77 | def test_follow_accept_announce_and_route(two): |
| 78 | a, b = two |
| 79 | status, actor = http_json("GET", f"{b.url}/actor") # public, no token |
| 80 | assert status == 200 and actor["type"] == "Hub" and actor["inbox"] == f"{b.url}/inbox" and actor["follows"] == "open" |
| 81 | |
| 82 | status, out = a.post("/follow", {"url": b.url}) |
| 83 | assert status == 200 and out["following"]["url"] == b.url |
| 84 | # The handshake is synchronous: B fetched A's actor, accepted, announced. |
| 85 | assert a.fed.following[b.url].state == "accepted" and a.fed.following[b.url].link == "up" |
| 86 | assert b.fed.followers[a.url].state == "accepted" |
| 87 | assert [t["name"] for t in a.fed.following[b.url].trees] == ["beta"] |
| 88 | |
| 89 | _, caps = a.get("/capabilities") |
| 90 | beta = next(t for t in caps["trees"] if t["name"] == "beta") |
| 91 | assert beta["via"] == "federation" and beta["peer"] == b.url and beta["link"] == "up" |
| 92 | _, peers = a.get("/peers") |
| 93 | assert peers["id"] == a.url and peers["following"][0]["url"] == b.url and peers["followers"] == [] |
| 94 | _, peers = b.get("/peers") |
| 95 | assert peers["followers"][0]["url"] == a.url and peers["following"] == [] |
| 96 | |
| 97 | # A asks for B's tree: routed over federation, signed, one hop, and the answer says so. |
| 98 | _, out = a.post("/intent", {"text": "beta hello"}) |
| 99 | assert out["status"] == "done" and out["say"] == "beta: hello" |
| 100 | assert out["peer"] == b.svc_srv.url and out["via"] == [b.url] |
| 101 | assert out["trace"]["steps"][0]["kind"] == "intent" |
| 102 | _, out = a.post("/run", {"tree": "beta", "inputs": {"text": "x"}}) |
| 103 | assert out["status"] == "done" and out["via"] == [b.url] |
| 104 | # Its own service still comes first for its own trees. |
| 105 | _, out = a.post("/intent", {"text": "alpha hi"}) |
| 106 | assert out["status"] == "done" and "via" not in out |
| 107 | |
| 108 | # Being followed grants nothing: B does not route to A. |
| 109 | _, caps = b.get("/capabilities") |
| 110 | assert [t["name"] for t in caps["trees"]] == ["beta"] |
| 111 | _, out = b.post("/intent", {"text": "alpha hi"}) |
| 112 | assert out["reason"] == "no-match" |
| 113 | |
| 114 | _, box = a.get("/outbox") |
| 115 | assert [x["type"] for x in box["outbox"]] == ["Follow", "Announce"] and all(x["delivered"] for x in box["outbox"]) # the Follow, then our announce upward |
| 116 | assert [x["type"] for x in box["inbox"]] == ["Accept", "Announce"] |
| 117 | |
| 118 | |
| 119 | def test_signed_requests_replace_the_token_only_for_hubs_we_know(two): |
| 120 | a, b = two |
| 121 | a.post("/follow", {"url": b.url}) |
| 122 | secret = a.fed.following[b.url].secret |
| 123 | body = {"tree": "beta", "inputs": {"text": "x"}} |
| 124 | # No token, no signature: refused. |
| 125 | assert http_json("POST", f"{b.url}/run", body)[0] == 401 |
| 126 | # Signed by A with the pair secret: served. |
| 127 | status, out = http_json("POST", f"{b.url}/run", body, headers=sign(secret, a.url, "POST", "/run", body)) |
| 128 | assert status == 200 and out["status"] == "done" |
| 129 | # Wrong secret, wrong actor, stale date: refused. |
| 130 | assert http_json("POST", f"{b.url}/run", body, headers=sign("nope", a.url, "POST", "/run", body))[0] == 401 |
| 131 | assert http_json("POST", f"{b.url}/run", body, headers=sign(secret, "http://stranger", "POST", "/run", body))[0] == 401 |
| 132 | assert http_json("POST", f"{b.url}/run", body, headers=sign(secret, a.url, "POST", "/run", body, date=time.time() - 3600))[0] == 401 |
| 133 | # A signature does not open the operator's paths. |
| 134 | assert http_json("GET", f"{b.url}/peers", headers=sign(secret, a.url, "GET", "/peers", None))[0] == 401 |
| 135 | assert http_json("POST", f"{b.url}/follow", {"url": a.url}, headers=sign(secret, a.url, "POST", "/follow", {"url": a.url}))[0] == 401 |
| 136 | # The inbox checks the activity's actor against the signature's. |
| 137 | announce = {"type": "Announce", "actor": a.url, "object": {"type": "Capabilities", "trees": []}} |
| 138 | status, out = http_json("POST", f"{b.url}/inbox", announce, headers=sign(secret, a.url, "POST", "/inbox", announce)) |
| 139 | # A follows B, so B takes A's announce for the directory it carries, and nothing else: |
| 140 | # being followed grants nothing, so B still routes only to its own trees. |
| 141 | assert status == 200 and out["status"] == "ok" and out["trees"] == 0 |
| 142 | assert [t["name"] for t in b.get("/capabilities")[1]["trees"]] == ["beta"] |
| 143 | stranger = {"type": "Announce", "actor": "http://stranger", "object": {"type": "Capabilities", "trees": []}} |
| 144 | assert http_json("POST", f"{b.url}/inbox", stranger, headers=sign(secret, "http://stranger", "POST", "/inbox", stranger))[0] == 403 |
| 145 | bad = {"type": "Announce", "actor": b.url, "object": {"type": "Capabilities", "trees": []}} |
| 146 | assert http_json("POST", f"{a.url}/inbox", bad, headers=sign("nope", b.url, "POST", "/inbox", bad))[0] == 401 |
| 147 | assert http_json("POST", f"{a.url}/inbox", {"type": "Dance", "actor": b.url}, headers=sign(secret, b.url, "POST", "/inbox", {"type": "Dance", "actor": b.url}))[0] == 400 |
| 148 | |
| 149 | |
| 150 | def test_federation_is_one_hop(two, tmp_path): |
| 151 | a, b = two |
| 152 | (tmp_path / "c").mkdir() |
| 153 | (tmp_path / "c" / "gamma.toml").write_text(ECHO.format(name="gamma", word="gamma")) |
| 154 | c = Swarm("c", tmp_path / "c") |
| 155 | try: |
| 156 | c.post("/follow", {"url": a.url}) |
| 157 | a.post("/follow", {"url": b.url}) |
| 158 | # C knows A's own trees, not what A learned from B. |
| 159 | _, caps = c.get("/capabilities") |
| 160 | assert {t["name"] for t in caps["trees"]} == {"gamma", "alpha"} |
| 161 | assert c.post("/intent", {"text": "beta hello"})[1]["reason"] == "no-match" |
| 162 | assert c.post("/run", {"tree": "beta", "inputs": {"text": "x"}})[1]["reason"] == "no-such-tree" |
| 163 | # A request that already crossed a hub is not forwarded again. |
| 164 | secret = c.fed.following[a.url].secret |
| 165 | body = {"tree": "beta", "inputs": {"text": "x"}, "hops": [c.url]} |
| 166 | status, out = http_json("POST", f"{a.url}/run", body, headers=sign(secret, c.url, "POST", "/run", body)) |
| 167 | assert status == 200 and out["reason"] == "hop-limit" |
| 168 | # A mutual follow does not loop: a sentence nobody answers is simply unanswered. |
| 169 | b.post("/follow", {"url": a.url}) |
| 170 | assert a.post("/intent", {"text": "nobody answers this"})[1]["reason"] == "no-match" |
| 171 | assert b.post("/intent", {"text": "alpha hi"})[1]["status"] == "done" |
| 172 | finally: |
| 173 | c.stop() |
| 174 | |
| 175 | |
| 176 | def test_follow_policy_secret_and_closed(tmp_path): |
| 177 | (tmp_path / "t").mkdir() |
| 178 | (tmp_path / "t" / "alpha.toml").write_text(ECHO.format(name="alpha", word="alpha")) |
| 179 | guarded = Swarm("guarded", tmp_path / "t", secret="federation-secret") |
| 180 | stranger = Swarm("stranger", tmp_path / "t") |
| 181 | insider = Swarm("insider", tmp_path / "t", secret="federation-secret") |
| 182 | closed = Swarm("closed", tmp_path / "t", open_follows=False) |
| 183 | try: |
| 184 | assert http_json("GET", f"{guarded.url}/actor")[1]["follows"] == "secret" |
| 185 | stranger.post("/follow", {"url": guarded.url}) |
| 186 | assert stranger.fed.following[guarded.url].state == "pending" |
| 187 | assert "federation secret" in stranger.fed.following[guarded.url].last_error |
| 188 | assert stranger.url not in guarded.fed.followers |
| 189 | insider.post("/follow", {"url": guarded.url}) |
| 190 | assert insider.fed.following[guarded.url].state == "accepted" |
| 191 | stranger.post("/follow", {"url": closed.url}) |
| 192 | assert stranger.fed.following[closed.url].state == "pending" and "does not take follows" in stranger.fed.following[closed.url].last_error |
| 193 | # A closed hub can still follow. |
| 194 | closed.post("/follow", {"url": stranger.url}) |
| 195 | assert closed.fed.following[stranger.url].state == "accepted" |
| 196 | status, out = stranger.post("/follow", {"url": stranger.url}) |
| 197 | assert status == 400 and "itself" in out["say"] |
| 198 | status, out = stranger.post("/follow", {"url": stranger.svc_srv.url}) |
| 199 | assert status == 400 and "does not answer /actor" in out["say"] |
| 200 | finally: |
| 201 | for s in (guarded, stranger, insider, closed): |
| 202 | s.stop() |
| 203 | |
| 204 | |
| 205 | def test_unfollow_announce_on_change_and_state(two, tmp_path): |
| 206 | a, b = two |
| 207 | state = tmp_path / "a-state.json" |
| 208 | a.fed.state_path = state |
| 209 | a.post("/follow", {"url": b.url}) |
| 210 | assert json.loads(state.read_text())["following"][0]["url"] == b.url |
| 211 | # B gains a tree; its next poll notices and announces; A learns without polling B. |
| 212 | (tmp_path / "b" / "beta2.toml").write_text(ECHO.format(name="beta-two", word="betatwo")) |
| 213 | b.hub.refresh_all() |
| 214 | assert {t["name"] for t in a.fed.following[b.url].trees} == {"beta", "beta-two"} |
| 215 | assert b.fed.announce() == 0 # nothing changed since: no delivery |
| 216 | # A fresh federation over the same file remembers the pair. |
| 217 | again = Federation(a.url, state=state) |
| 218 | assert again.following[b.url].secret == a.fed.following[b.url].secret and again.following[b.url].state == "accepted" |
| 219 | # Unfollow: both sides forget, and A stops routing there. |
| 220 | status, out = a.post("/unfollow", {"url": b.url}) |
| 221 | assert status == 200 and out["removed"] is True |
| 222 | assert b.url not in a.fed.following and a.url not in b.fed.followers |
| 223 | assert a.post("/intent", {"text": "beta hello"})[1]["reason"] == "no-match" |
| 224 | assert a.post("/unfollow", {"url": b.url})[1]["removed"] is False |
| 225 | saved = json.loads(state.read_text()) |
| 226 | assert saved["following"] == [] and saved["followers"] == [] and saved["listed"] is False |
| 227 | |
| 228 | |
| 229 | def test_unfederated_hub_has_no_inbox(tmp_path): |
| 230 | hub = Hub() |
| 231 | srv = Server(hub.route, quiet=True).start() |
| 232 | try: |
| 233 | assert http_json("GET", f"{srv.url}/actor")[0] == 404 |
| 234 | assert http_json("POST", f"{srv.url}/follow", {"url": "http://x"})[0] == 404 |
| 235 | assert http_json("GET", f"{srv.url}/api")[1]["federation"] is None |
| 236 | finally: |
| 237 | srv.stop() |
| 238 | |
| 239 | |
| 240 | def test_cli_follow_peers_unfollow(two, capsys): |
| 241 | a, b = two |
| 242 | |
| 243 | def run(*argv): |
| 244 | code = main(list(argv)) |
| 245 | out = capsys.readouterr() |
| 246 | return code, out.out, out.err |
| 247 | |
| 248 | code, out, _ = run("follow", b.url, "--at", a.url, "--token", "token-a") |
| 249 | assert code == 0 and "now follows" in out and "accepted" in out |
| 250 | code, out, _ = run("peers", "--at", a.url, "--token", "token-a") |
| 251 | assert code == 0 and "follows" in out and "beta" in out and "a-svc" in out |
| 252 | code, out, _ = run("peers", "--at", b.url, "--token", "token-b") |
| 253 | assert "follower" in out |
| 254 | assert run("follow", b.url, "--at", a.url)[0] == 3 # no token |
| 255 | code, out, _ = run("unfollow", b.url, "--at", a.url, "--token", "token-a") |
| 256 | assert code == 0 and "no longer follows" in out |
| 257 | code, _, err = run("follow", "http://127.0.0.1:9", "--at", a.url, "--token", "token-a") |
| 258 | assert code == 1 and "does not answer /actor" in err |
| 259 | |
| 260 | |
| 261 | def test_signing_primitives(): |
| 262 | headers = sign("s", "http://a", "POST", "/inbox", {"b": 1, "a": [1, 2]}) |
| 263 | assert verify("s", "POST", "/inbox", {"a": [1, 2], "b": 1}, headers) is None # key order does not matter |
| 264 | assert verify("s", "POST", "/inbox", {"a": [1, 2], "b": 2}, headers) == "bad signature" |
| 265 | assert verify("t", "POST", "/inbox", {"a": [1, 2], "b": 1}, headers) == "bad signature" |
| 266 | assert verify("s", "POST", "/other", {"a": [1, 2], "b": 1}, headers) == "bad signature" |
| 267 | assert verify("s", "POST", "/inbox", {"a": [1, 2], "b": 1}, {}) == "no signature" |
| 268 | assert verify("s", "POST", "/inbox", {"a": [1, 2], "b": 1}, headers, now=time.time() + 1000) == "signature too old" |
| 269 | assert Remote("http://b", "http://b/inbox", "s").link == "pending" |
| 270 | assert Remote("http://b", "http://b/inbox", "s", state="accepted").link == "pending" # accepted, not heard from yet |
| 271 | assert Remote("http://b", "http://b/inbox", "s", state="accepted", last_error="x").link == "down" |
| 272 | assert Remote("http://b", "http://b/inbox", "s", state="rejected").link == "down" |
| 273 | |
| 274 | |
| 275 | def test_directory_is_gossiped_in_both_directions_and_routes_to_nobody(two, tmp_path): |
| 276 | a, b = two |
| 277 | (tmp_path / "c").mkdir() |
| 278 | (tmp_path / "c" / "gamma.toml").write_text(ECHO.format(name="gamma", word="gamma")) |
| 279 | c = Swarm("c", tmp_path / "c", listed=True) |
| 280 | try: |
| 281 | # B lists itself; A follows B and hears about B in the Accept's Announce. |
| 282 | b.fed.set_listed(True) |
| 283 | a.post("/follow", {"url": b.url}) |
| 284 | status, d = http_json("GET", f"{a.url}/directory") # public |
| 285 | assert status == 200 and d["hub"] == a.url and [h["id"] for h in d["hubs"]] == [b.url] |
| 286 | assert d["hubs"][0]["trees"] == ["beta"] and d["hubs"][0]["link"] == "up" and d["hubs"][0]["via"] == b.url |
| 287 | # C (listed) follows B: it announces upward, so B hears of C; A hears of C from B on B's next announce. |
| 288 | c.post("/follow", {"url": b.url}) |
| 289 | assert {h["id"] for h in b.fed.directory()["hubs"]} == {b.url, c.url} |
| 290 | assert c.url not in {h["id"] for h in a.fed.directory()["hubs"]} |
| 291 | b.hub.refresh_all() # B announces downward (the directory changed) and A merges |
| 292 | assert {h["id"] for h in a.fed.directory()["hubs"]} == {b.url, c.url} |
| 293 | assert next(h for h in a.fed.directory()["hubs"] if h["id"] == c.url)["via"] == b.url |
| 294 | # And C hears of nobody but B until B announces to it; after that, the same two. |
| 295 | assert {h["id"] for h in c.fed.directory()["hubs"]} >= {b.url, c.url} |
| 296 | # A is not listed, so it appears nowhere; listing itself puts it in its own gossip and up to B. |
| 297 | assert a.url not in {h["id"] for h in b.fed.directory()["hubs"]} |
| 298 | a.post("/listed", {"listed": True}) |
| 299 | assert a.url in {h["id"] for h in b.fed.directory()["hubs"]} |
| 300 | assert a.fed.directory()["hubs"][0]["id"] == a.url and a.fed.directory()["hubs"][0]["via"] == "self" |
| 301 | # Nothing in the directory is routed to: B still answers only for itself. |
| 302 | assert b.post("/intent", {"text": "alpha hello"})[1]["reason"] == "no-match" |
| 303 | assert [t["name"] for t in b.get("/capabilities")[1]["trees"]] == ["beta"] |
| 304 | # The directory survives a restart through the state file. |
| 305 | state = tmp_path / "state.json" |
| 306 | a.fed.state_path = state |
| 307 | a.fed._save() |
| 308 | again = Federation(a.url, state=state) |
| 309 | assert again.listed and {h["id"] for h in again.directory()["hubs"]} == {a.url, b.url, c.url} |
| 310 | # Merge rules: our own entry is never overwritten, the future is not believed, old news is dropped. |
| 311 | a.fed.merge_directory([{"id": a.url, "name": "impostor", "trees": ["x"], "seen": time.time() + 1e6}], via="x") |
| 312 | assert a.fed.directory()["hubs"][0]["name"] == "a" |
| 313 | a.fed.merge_directory([{"id": "http://future", "trees": [], "seen": time.time() + 1e6}], via="x") |
| 314 | assert abs(a.fed.directory_entries["http://future"]["seen"] - time.time()) < 5 |
| 315 | assert a.fed.merge_directory([{"id": "http://old", "trees": [], "seen": time.time() - 30 * 86400}], via="x") == 0 |
| 316 | assert a.fed.merge_directory([{"id": "not a url"}, "junk", {"id": "http://ok", "seen": "soon"}], via="x") == 0 |
| 317 | assert "/directory" in http_json("GET", f"{a.url}/api")[1]["endpoints"] |
| 318 | finally: |
| 319 | c.stop() |
| 320 | |
| 321 | |
| 322 | def test_hub_list_and_cli_defaults(two, capsys, monkeypatch, tmp_path): |
| 323 | a, b = two |
| 324 | from pyrtan.federation import first_live_hub, rendezvous_hubs |
| 325 | |
| 326 | def run(*argv): |
| 327 | code = main(list(argv)) |
| 328 | out = capsys.readouterr() |
| 329 | return code, out.out, out.err |
| 330 | |
| 331 | listed = tmp_path / "hubs.json" |
| 332 | listed.write_text(json.dumps({"pyrtan-hubs": 1, "hubs": ["http://127.0.0.1:9", b.url, a.url]})) |
| 333 | monkeypatch.setenv("PYRTAN_HUB_LIST", str(listed)) |
| 334 | monkeypatch.setenv("PYRTAN_SNAPSHOT", str(tmp_path / "no-snapshot.json")) # never reach pyrtan.com from a test |
| 335 | monkeypatch.delenv("PYRTAN_RENDEZVOUS", raising=False) |
| 336 | assert rendezvous_hubs() == ["http://127.0.0.1:9", b.url, a.url] |
| 337 | assert first_live_hub() == b.url # the dead one is skipped |
| 338 | monkeypatch.setenv("PYRTAN_RENDEZVOUS", a.url) |
| 339 | assert rendezvous_hubs() == [a.url] |
| 340 | monkeypatch.delenv("PYRTAN_RENDEZVOUS") |
| 341 | b.fed.set_listed(True) |
| 342 | code, out, _ = run("follow", "--at", a.url, "--token", "token-a", "--listed") # no URL: the first listed hub that answers |
| 343 | assert code == 0 and f"now follows {b.url}" in out and "listed in the directory" in out |
| 344 | assert a.fed.listed and a.url in {h["id"] for h in b.fed.directory()["hubs"]} |
| 345 | monkeypatch.setenv("PYRTAN_AT", "http://127.0.0.1:9") # the local default is dead: fall through to the hub list |
| 346 | code, out, _ = run("directory") |
| 347 | assert code == 0 and f"as b ({b.url})" in out and a.url in out and b.url in out |
| 348 | code, out, _ = run("directory", "--at", a.url, "--json") |
| 349 | assert {h["id"] for h in json.loads(out)["hubs"]} == {a.url, b.url} |
| 350 | assert run("directory", "--at", b.svc_srv.url)[0] == 3 |
| 351 | monkeypatch.setenv("PYRTAN_HUB_LIST", str(tmp_path / "missing.json")) |
| 352 | assert rendezvous_hubs() == [] |
| 353 | code, _, err = run("follow", "--at", a.url, "--token", "token-a") |
| 354 | assert code == 3 and "no listed or snapshot hub answers" in err |
| 355 | |
| 356 | |
| 357 | def test_hub_holds_trees_itself(tmp_path): |
| 358 | (tmp_path / "t").mkdir() |
| 359 | (tmp_path / "t" / "alpha.toml").write_text(ECHO.format(name="alpha", word="alpha")) |
| 360 | (tmp_path / "t" / "picnic.toml").write_text(""" |
| 361 | name = "picnic" |
| 362 | description = "delegates to the hub it lives in" |
| 363 | start = "ask" |
| 364 | |
| 365 | [[triggers]] |
| 366 | pattern = "picnic {text}" |
| 367 | |
| 368 | [inputs.text] |
| 369 | type = "str" |
| 370 | |
| 371 | [nodes.ask] |
| 372 | kind = "delegate" |
| 373 | intent = "alpha {{ inputs.text }}" |
| 374 | into = "a" |
| 375 | next = "say" |
| 376 | |
| 377 | [nodes.say] |
| 378 | kind = "done" |
| 379 | say = "picnic says {{ state.a.say }}" |
| 380 | """) |
| 381 | from pyrtan.hub import serve_hub |
| 382 | |
| 383 | hub, srv = serve_hub(port=0, quiet=True, name="solo", trees=[tmp_path / "t"], federate=True) |
| 384 | try: |
| 385 | _, health = http_json("GET", f"{srv.url}/health") |
| 386 | assert health["local"] == 2 and health["trees"] == 2 and health["peers"] == 0 |
| 387 | _, caps = http_json("GET", f"{srv.url}/capabilities") |
| 388 | assert {(t["name"], t["peer"]) for t in caps["trees"]} == {("alpha", "local"), ("picnic", "local")} |
| 389 | _, out = http_json("POST", f"{srv.url}/intent", {"text": "alpha hi"}) |
| 390 | assert out["status"] == "done" and out["say"] == "alpha: hi" and out["peer"] == "local" |
| 391 | assert out["trace"]["steps"][0]["kind"] == "intent" |
| 392 | # A local tree's delegate goes back through the hub, in-process. |
| 393 | _, out = http_json("POST", f"{srv.url}/run", {"tree": "picnic", "inputs": {"text": "now"}}) |
| 394 | assert out["status"] == "done" and out["say"] == "picnic says alpha: now" |
| 395 | _, peers = http_json("GET", f"{srv.url}/peers") |
| 396 | assert peers["peers"][0]["kind"] == "local" and peers["peers"][0]["trees"] == ["alpha", "picnic"] |
| 397 | assert http_json("GET", f"{srv.url}/gaps")[1]["gaps"] == [] |
| 398 | # Hot reload holds for local trees too, and the announce digest follows. |
| 399 | (tmp_path / "t" / "beta.toml").write_text(ECHO.format(name="beta", word="beta")) |
| 400 | assert "beta" in {t["name"] for t in hub.local_capabilities()} |
| 401 | finally: |
| 402 | hub.stop() |
| 403 | srv.stop() |
| 404 | |
| 405 | |
| 406 | def test_snapshot_bakes_and_bootstraps(two, tmp_path, monkeypatch, capsys): |
| 407 | """site/snapshot.py merges the listed hubs' directories with the last snapshot, |
| 408 | and the command falls back to the snapshot when nothing is live.""" |
| 409 | import subprocess |
| 410 | import sys as _sys |
| 411 | |
| 412 | a, b = two |
| 413 | b.fed.set_listed(True) |
| 414 | a.post("/follow", {"url": b.url}) |
| 415 | listed = tmp_path / "hubs.json" |
| 416 | listed.write_text(json.dumps({"pyrtan-hubs": 1, "hubs": ["http://127.0.0.1:9", a.url]})) |
| 417 | old = tmp_path / "old.json" |
| 418 | old.write_text(json.dumps({"pyrtan-directory": 1, "hubs": [{"id": "http://gone.example", "name": "gone", "trees": ["x"], "seen": time.time() - 3600}, {"id": "http://ancient.example", "trees": [], "seen": time.time() - 30 * 86400}]})) |
| 419 | out = tmp_path / "directory.json" |
| 420 | run = subprocess.run([_sys.executable, "site/snapshot.py", str(listed), str(out), "--previous", str(tmp_path / "missing.json"), "--previous", str(old)], capture_output=True, text=True, cwd=str(EXAMPLES.parent)) |
| 421 | assert run.returncode == 0, run.stderr |
| 422 | snap = json.loads(out.read_text()) |
| 423 | assert snap["pyrtan-directory"] == 1 and snap["listed"]["answered"] == [a.url] and snap["listed"]["silent"][0]["hub"] == "http://127.0.0.1:9" |
| 424 | assert snap["listed"]["previous"] == str(old) |
| 425 | ids = [h["id"] for h in snap["hubs"]] |
| 426 | assert b.url in ids and "http://gone.example" in ids and "http://ancient.example" not in ids # live from the listed hub, kept from the snapshot, expired |
| 427 | # B was named by A, so it was asked too, and its own word on itself is the newest. |
| 428 | assert snap["listed"]["crawled"]["answered"] == [b.url] and next(h for h in snap["hubs"] if h["id"] == b.url)["via"] == b.url |
| 429 | |
| 430 | def cli(*argv): |
| 431 | code = main(list(argv)) |
| 432 | o = capsys.readouterr() |
| 433 | return code, o.out, o.err |
| 434 | |
| 435 | from pyrtan.federation import bootstrap_candidates, first_live_hub, snapshot_hubs |
| 436 | |
| 437 | # With every listed hub dead, the snapshot's hubs are the next place to look. |
| 438 | monkeypatch.setenv("PYRTAN_HUB_LIST", str(tmp_path / "dead-hubs.json")) |
| 439 | (tmp_path / "dead-hubs.json").write_text(json.dumps({"hubs": ["http://127.0.0.1:9"]})) |
| 440 | monkeypatch.setenv("PYRTAN_SNAPSHOT", str(out)) |
| 441 | monkeypatch.delenv("PYRTAN_RENDEZVOUS", raising=False) |
| 442 | assert snapshot_hubs()[0] == b.url # most recently heard first |
| 443 | assert bootstrap_candidates() == ["http://127.0.0.1:9", b.url, "http://gone.example"] |
| 444 | assert first_live_hub() == b.url |
| 445 | code, o, _ = cli("follow", "--at", a.url, "--token", "token-a") |
| 446 | assert code == 0 and f"now follows {b.url}" in o |
| 447 | monkeypatch.setenv("PYRTAN_AT", "http://127.0.0.1:9") |
| 448 | b.stop() # nothing live at all now: the snapshot, with its age, and link recomputed |
| 449 | code, o, _ = cli("directory") |
| 450 | assert code == 0 and "snapshot as of" in o and b.url in o and "gone" in o |
| 451 | monkeypatch.setenv("PYRTAN_SNAPSHOT", str(tmp_path / "nope.json")) |
| 452 | assert cli("directory")[0] == 3 |
| 453 | |
| 454 | |
| 455 | # ---------------------------------------------------------------- members and keeping |
| 456 | |
| 457 | |
| 458 | class Member: |
| 459 | """A browser or a phone, in Python: no inbox, a mailbox it polls.""" |
| 460 | |
| 461 | def __init__(self, hub_url: str, name: str = "phone"): |
| 462 | import secrets as _secrets |
| 463 | |
| 464 | self.id = f"urn:pyrtan:member:{_secrets.token_hex(16)}" |
| 465 | self.secret = _secrets.token_hex(32) |
| 466 | self.hub = hub_url.rstrip("/") |
| 467 | self.name = name |
| 468 | self.kept: dict[str, dict] = {} |
| 469 | self.directory: list[dict] = [] |
| 470 | |
| 471 | def post(self, activity: dict, secret: str | None = None): |
| 472 | activity = {"id": f"{self.id}/activities/{len(self.kept)}", "actor": self.id, "published": "now", **activity} |
| 473 | return http_json("POST", f"{self.hub}/inbox", activity, headers=sign(secret or self.secret, self.id, "POST", "/inbox", activity)) |
| 474 | |
| 475 | def follow(self, secret: str | None = None): |
| 476 | return self.post({"type": "Follow", "object": self.hub, "secret": self.secret, "inbox": "mailbox", "name": self.name}, secret) |
| 477 | |
| 478 | def poll(self) -> list[dict]: |
| 479 | status, body = http_json("GET", f"{self.hub}/mailbox", headers=sign(self.secret, self.id, "GET", "/mailbox", None)) |
| 480 | assert status == 200, body |
| 481 | acts = body["activities"] |
| 482 | for a in acts: |
| 483 | if a["type"] == "Keep": |
| 484 | self.kept[a["object"]["key"]] = a["object"] |
| 485 | self.post({"type": "Kept", "object": {"key": a["object"]["key"], "sha256": a["object"]["sha256"]}}) |
| 486 | elif a["type"] == "Fetch": |
| 487 | blob = self.kept.get(a["object"]["key"]) |
| 488 | if blob: |
| 489 | self.post({"type": "Kept", "object": {"key": blob["key"], "sha256": blob["sha256"], "data": blob["data"]}}) |
| 490 | elif a["type"] == "Announce": |
| 491 | self.directory = a["object"].get("directory", []) |
| 492 | return acts |
| 493 | |
| 494 | def announce(self, listed: bool = True): |
| 495 | own = [{"id": self.id, "name": self.name, "kind": "member", "trees": [], "seen": time.time()}] if listed else [] |
| 496 | return self.post({"type": "Announce", "object": {"type": "Capabilities", "name": self.name, "trees": [], "directory": own, "kept": sorted(self.kept)}}) |
| 497 | |
| 498 | |
| 499 | def test_member_follows_through_a_mailbox_and_keeps_blobs(two, tmp_path): |
| 500 | a, b = two |
| 501 | a.fed.store = __import__("pyrtan.store", fromlist=["BlobStore"]).BlobStore(tmp_path / "store-a") |
| 502 | m = Member(a.url) |
| 503 | status, out = m.follow() |
| 504 | assert status == 202 and out["mailbox"] == f"{a.url}/mailbox" |
| 505 | assert a.fed.followers[m.id].kind == "member" and a.fed.followers[m.id].inbox == "mailbox" |
| 506 | # The Accept and the first Announce wait in the mailbox; polling drains it. |
| 507 | acts = m.poll() |
| 508 | assert [x["type"] for x in acts] == ["Accept", "Announce"] and m.poll() == [] |
| 509 | _, peers = a.get("/peers") |
| 510 | assert peers["followers"][0]["kind"] == "member" and peers["followers"][0]["link"] == "up" |
| 511 | # The member lists itself by announcing upward; the hub's directory carries it as a member. |
| 512 | status, out = m.announce() |
| 513 | assert status == 200 and out["directory"] is not None |
| 514 | entry = next(h for h in a.fed.directory()["hubs"] if h["id"] == m.id) |
| 515 | assert entry["kind"] == "member" and entry["via"] == m.id |
| 516 | # A blob of the hub's own goes to the member through its mailbox; the member's Kept confirms it. |
| 517 | status, out = a.post("/keep", {"key": "notes/today", "data": {"todo": ["water plants"]}}) |
| 518 | assert status == 200 and out["keeping"]["holders"] == [] # nobody has confirmed yet |
| 519 | acts = m.poll() |
| 520 | assert [x["type"] for x in acts] == ["Keep"] and m.kept["notes/today"]["data"] == {"todo": ["water plants"]} |
| 521 | assert a.fed.holders("notes/today") == [m.id] |
| 522 | _, out = a.get("/keep") |
| 523 | assert out["keeping"][0]["key"] == "notes/today" and out["keeping"][0]["holders"] == [m.id] and out["replicas"] == 2 |
| 524 | # Fetch: the hub still has its own copy, so no round trip. |
| 525 | assert a.post("/fetch", {"key": "notes/today"})[1]["source"] == "own" |
| 526 | # The hub loses its store; the member's Announce says what it keeps, the hub asks, the member answers, the blob is back. |
| 527 | a.fed.store = __import__("pyrtan.store", fromlist=["BlobStore"]).BlobStore(None) |
| 528 | assert a.get("/keep/notes/today")[0] == 404 |
| 529 | _, out = a.post("/fetch", {"key": "notes/today"}) |
| 530 | assert out["status"] == "asked" and out["asked"] == [m.id] |
| 531 | acts = m.poll() |
| 532 | assert [x["type"] for x in acts] == ["Fetch"] |
| 533 | _, out = a.get("/keep/notes/today") |
| 534 | assert out["data"] == {"todo": ["water plants"]} and out["holders"] == [m.id] |
| 535 | # Or without asking: the member's next upward Announce names the key, the hub notices it has no copy and fetches. |
| 536 | a.fed.store = __import__("pyrtan.store", fromlist=["BlobStore"]).BlobStore(None) |
| 537 | m.announce() |
| 538 | assert [x["type"] for x in m.poll()] == ["Fetch"] |
| 539 | assert a.get("/keep/notes/today")[1]["data"] == {"todo": ["water plants"]} |
| 540 | # Leaving takes the mailbox and the kept blobs with it. |
| 541 | assert m.post({"type": "Undo", "object": {"type": "Follow", "actor": m.id, "object": a.url}})[0] == 200 |
| 542 | assert m.id not in a.fed.followers and m.id not in a.fed.mailboxes |
| 543 | assert http_json("GET", f"{a.url}/mailbox", headers=sign(m.secret, m.id, "GET", "/mailbox", None))[0] == 401 |
| 544 | # A member's id has a shape; a stranger's signature opens nothing. |
| 545 | bad = Member(a.url); bad.id = "urn:pyrtan:member:short" |
| 546 | assert bad.follow()[0] == 400 |
| 547 | assert http_json("GET", f"{a.url}/mailbox", headers=sign("x", "urn:pyrtan:member:" + "0" * 32, "GET", "/mailbox", None))[0] == 401 |
| 548 | |
| 549 | |
| 550 | def test_hubs_keep_for_each_other_and_the_store_has_limits(two, tmp_path): |
| 551 | from pyrtan.store import MAX_BLOB, BlobStore, StoreError |
| 552 | |
| 553 | a, b = two |
| 554 | a.fed.store = BlobStore(tmp_path / "a"); b.fed.store = BlobStore(tmp_path / "b") |
| 555 | a.post("/follow", {"url": b.url}) |
| 556 | # A keeps a blob; B (a hub A follows) is a keeper and confirms in the same round trip. |
| 557 | status, out = a.post("/keep", {"key": "gaps-archive", "data": [{"tree": "x"}]}) |
| 558 | assert status == 200 and out["keeping"]["holders"] == [b.url] |
| 559 | assert b.fed.store.list_kept(a.url)[0]["key"] == "gaps-archive" |
| 560 | assert [k["key"] for k in b.get("/keep")[1]["kept"]] == ["gaps-archive"] |
| 561 | # A takes it back from B over the kept path, signed; a stranger cannot. |
| 562 | path = "/kept/gaps-archive" |
| 563 | status, blob = http_json("GET", f"{b.url}{path}", headers=sign(a.fed.following[b.url].secret, a.url, "GET", path, None)) |
| 564 | assert status == 200 and blob["data"] == [{"tree": "x"}] |
| 565 | assert http_json("GET", f"{b.url}{path}", headers=sign("nope", a.url, "GET", path, None))[0] == 401 |
| 566 | assert http_json("GET", f"{b.url}/kept/other", headers=sign(a.fed.following[b.url].secret, a.url, "GET", "/kept/other", None))[0] == 404 |
| 567 | # Fetch after A's store is gone: B answers at once. |
| 568 | a.fed.store = BlobStore(tmp_path / "a2") |
| 569 | _, out = a.post("/fetch", {"key": "gaps-archive"}) |
| 570 | assert out["status"] == "ok" and out["source"] == b.url and out["blob"]["data"] == [{"tree": "x"}] |
| 571 | # The automatic blobs appear on the poll and change when the directory does. |
| 572 | a.hub.refresh_all() |
| 573 | keys = {k["key"] for k in a.get("/keep")[1]["keeping"]} |
| 574 | assert {"directory", "gaps", "unanswered", "gaps-archive"} <= keys |
| 575 | # Limits: a key has a shape, a blob a size, an actor a budget; and the state file remembers who holds what. |
| 576 | assert a.post("/keep", {"key": "Bad Key", "data": 1})[0] == 400 |
| 577 | assert a.post("/keep", {"key": "big", "data": "x" * (MAX_BLOB + 1)})[0] == 400 |
| 578 | with pytest.raises(StoreError): |
| 579 | b.fed.store.put_kept(a.url, "k", {"x": 1}, sha256="wrong") |
| 580 | state = tmp_path / "state.json" |
| 581 | a.fed.state_path = state; a.fed._save() |
| 582 | again = Federation(a.url, state=state, store=BlobStore(tmp_path / "a2")) |
| 583 | assert again.keeping["gaps-archive"]["holders"][b.url]["sha256"] == a.fed.keeping["gaps-archive"]["sha256"] |
| 584 | # Unfollow drops what B kept for A. |
| 585 | a.post("/unfollow", {"url": b.url}) |
| 586 | assert b.fed.store.list_kept(a.url) == [] |
| 587 | |
| 588 | |
| 589 | def test_replication_counts_fresh_holders_only(two, tmp_path): |
| 590 | from pyrtan.store import BlobStore |
| 591 | |
| 592 | a, b = two |
| 593 | a.fed.store = BlobStore(None); a.fed.replicas = 2 |
| 594 | m1, m2, m3 = Member(a.url, "m1"), Member(a.url, "m2"), Member(a.url, "m3") |
| 595 | for m in (m1, m2, m3): |
| 596 | m.follow(); m.poll() |
| 597 | a.post("/keep", {"key": "k", "data": 1}) |
| 598 | # Two members got a Keep (replicas = 2), the third did not. |
| 599 | got = [m for m in (m1, m2, m3) if any(x["type"] == "Keep" for x in m.poll())] |
| 600 | assert len(got) == 2 and a.fed.holders("k") == sorted(m.id for m in got) |
| 601 | assert a.fed.replicate() == 0 # enough holders: nothing sent |
| 602 | # A holder goes stale: the next replicate sends to the third. |
| 603 | a.fed.keeping["k"]["holders"][got[0].id]["at"] -= 2 * 86400 |
| 604 | assert a.fed.replicate() == 1 |
| 605 | third = next(m for m in (m1, m2, m3) if m not in got) |
| 606 | assert [x["type"] for x in third.poll()] == ["Keep"] and len(a.fed.holders("k")) == 2 |
| 607 | # A new version of the blob needs confirming again. |
| 608 | a.post("/keep", {"key": "k", "data": 2}) |
| 609 | assert a.fed.holders("k") == [] |
| 610 | for m in (m1, m2, m3): |
| 611 | m.poll() |
| 612 | assert len(a.fed.holders("k")) == 2 |
| 613 | |
| 614 | |
| 615 | def test_cli_keep_kept_fetch(two, tmp_path, capsys): |
| 616 | from pyrtan.store import BlobStore |
| 617 | |
| 618 | a, b = two |
| 619 | a.fed.store = BlobStore(None) |
| 620 | a.post("/follow", {"url": b.url}) |
| 621 | |
| 622 | def run(*argv): |
| 623 | code = main(list(argv)) |
| 624 | o = capsys.readouterr() |
| 625 | return code, o.out, o.err |
| 626 | |
| 627 | f = tmp_path / "blob.json"; f.write_text(json.dumps({"hello": "world"})) |
| 628 | code, out, _ = run("keep", "recipes/soup", str(f), "--at", a.url, "--token", "token-a") |
| 629 | assert code == 0 and "keeps recipes/soup" in out and "held by 1 of 2" in out |
| 630 | code, out, _ = run("kept", "--at", a.url, "--token", "token-a") |
| 631 | assert code == 0 and "recipes/soup" in out and b.url in out |
| 632 | code, out, _ = run("kept", "--at", b.url, "--token", "token-b") |
| 633 | assert f"for {a.url}: recipes/soup" in out |
| 634 | a.fed.store = BlobStore(None) |
| 635 | code, out, _ = run("fetch", "recipes/soup", "--at", a.url, "--token", "token-a", "--out", str(tmp_path / "back.json")) |
| 636 | assert code == 0 and f"from {b.url}" in out and json.loads((tmp_path / "back.json").read_text()) == {"hello": "world"} |
| 637 | assert run("fetch", "nothing", "--at", a.url, "--token", "token-a")[0] == 1 |
| 638 | assert run("keep", "bad key", str(f), "--at", a.url, "--token", "token-a")[0] == 1 |
| 639 | |
| 640 | |
| 641 | class TrustedMember(Member): |
| 642 | """A member that runs trees: it holds what the hub hands it and answers Asks.""" |
| 643 | |
| 644 | def __init__(self, hub_url: str, name: str = "phone", answers: dict | None = None): |
| 645 | super().__init__(hub_url, name) |
| 646 | self.trees: dict[str, dict] = {} |
| 647 | self.asks: list[dict] = [] |
| 648 | self.answers = answers or {} |
| 649 | |
| 650 | def follow(self, secret: str | None = None, token: str | None = None): |
| 651 | activity = {"id": f"{self.id}/activities/follow", "actor": self.id, "published": "now", "type": "Follow", "object": self.hub, "secret": self.secret, "inbox": "mailbox", "name": self.name} |
| 652 | headers = sign(secret or self.secret, self.id, "POST", "/inbox", activity) |
| 653 | if token: |
| 654 | headers["Authorization"] = f"Bearer {token}" |
| 655 | return http_json("POST", f"{self.hub}/inbox", activity, headers=headers) |
| 656 | |
| 657 | def poll(self) -> list[dict]: |
| 658 | acts = super().poll() |
| 659 | for a in acts: |
| 660 | if a["type"] == "Keep" and a["object"]["key"].startswith("trees/"): |
| 661 | self.trees[a["object"]["data"]["name"]] = a["object"]["data"] |
| 662 | elif a["type"] == "Ask": |
| 663 | self.asks.append(a["object"]) |
| 664 | outcome = self.answers.get(a["object"]["tree"]) |
| 665 | if outcome is not None: |
| 666 | self.post({"type": "Answer", "object": {"run": a["object"]["run"], "outcome": {**outcome, "output": {"inputs": a["object"]["inputs"]}}}}) |
| 667 | return acts |
| 668 | |
| 669 | def announce(self, listed: bool = True, trees: list | None = None): |
| 670 | caps = trees if trees is not None else [{"name": n, "triggers": ["whatever the member says"]} for n in self.trees] |
| 671 | return self.post({"type": "Announce", "object": {"type": "Capabilities", "name": self.name, "trees": caps, "directory": [], "kept": sorted(self.kept)}}) |
| 672 | |
| 673 | |
| 674 | def test_trusted_member_runs_the_trees_its_hub_hands_out(tmp_path): |
| 675 | (tmp_path / "t").mkdir() |
| 676 | (tmp_path / "h").mkdir() |
| 677 | (tmp_path / "t" / "alpha.toml").write_text(ECHO.format(name="alpha", word="alpha")) |
| 678 | (tmp_path / "h" / "gamma.toml").write_text(ECHO.format(name="gamma", word="gamma")) |
| 679 | (tmp_path / "h" / "box.toml").write_text(''' |
| 680 | name = "box" |
| 681 | commands = ["uptime"] |
| 682 | start = "up" |
| 683 | [nodes.up] |
| 684 | kind = "observe" |
| 685 | shell = { command = "uptime" } |
| 686 | next = "say" |
| 687 | [nodes.say] |
| 688 | kind = "done" |
| 689 | say = "{{ state.last.stdout }}" |
| 690 | ''') |
| 691 | from pyrtan.hub import serve_hub |
| 692 | |
| 693 | hub, srv = serve_hub(port=0, quiet=True, name="solo", trees=[tmp_path / "t"], handout=[tmp_path / "h"], federate=True, token="tok", poll_every=60) |
| 694 | fed = hub.federation |
| 695 | try: |
| 696 | # Joining with the hub's token makes a member trusted; without it, not. |
| 697 | stranger = TrustedMember(srv.url, "stranger") |
| 698 | assert stranger.follow()[1]["trusted"] is False |
| 699 | assert [a["type"] for a in stranger.poll()] == ["Accept", "Announce"] |
| 700 | stranger.announce(trees=[{"name": "alpha", "triggers": ["alpha {text}"]}]) |
| 701 | assert fed.followers[stranger.id].trees == [] and "alpha" not in stranger.trees |
| 702 | phone = TrustedMember(srv.url, "phone", answers={"gamma": {"status": "done", "say": "gamma, from the phone", "trace": {"tree": "gamma", "steps": [{"node": "say", "kind": "done", "depth": 0}], "gaps": []}}}) |
| 703 | status, out = phone.follow(token="tok") |
| 704 | assert status == 202 and out["trusted"] is True and fed.followers[phone.id].trusted |
| 705 | # The hub hands out its own trees and the --handout ones at once, in the mailbox after the Accept and the Announce. |
| 706 | acts = phone.poll() |
| 707 | assert [a["type"] for a in acts] == ["Accept", "Announce", "Keep", "Keep"] |
| 708 | assert sorted(phone.trees) == ["alpha", "gamma"] and phone.trees["gamma"]["name"] == "gamma" # not "box": a phone has no shell |
| 709 | assert all(h["confirmed"] for h in fed._handed[phone.id].values()) |
| 710 | assert fed.hand_out() == 0 # nothing new to give |
| 711 | # What the member says it runs is filtered to what it was handed, with the hub's own triggers. |
| 712 | status, out = phone.announce(trees=[{"name": "gamma", "triggers": ["nonsense"]}, {"name": "alpha", "triggers": ["alpha {text}"]}, {"name": "zeta", "triggers": ["zeta"]}]) |
| 713 | assert status == 200 |
| 714 | caps = {t["name"]: t for t in fed.followers[phone.id].trees} |
| 715 | assert sorted(caps) == ["alpha", "gamma"] and caps["gamma"]["triggers"] == ["gamma {text}"] |
| 716 | _, out = http_json("GET", f"{srv.url}/capabilities", token="tok") |
| 717 | assert {(t["name"], t["peer"], t.get("via")) for t in out["trees"]} == {("alpha", "local", None), ("alpha", phone.id, "member"), ("gamma", phone.id, "member")} |
| 718 | # A sentence for gamma goes to the phone: an Ask in its mailbox, its Answer back to the caller. |
| 719 | got: dict = {} |
| 720 | |
| 721 | def call(): |
| 722 | got["out"] = http_json("POST", f"{srv.url}/intent", {"text": "gamma hi there"}, token="tok")[1] |
| 723 | |
| 724 | t = threading.Thread(target=call) |
| 725 | t.start() |
| 726 | deadline = time.time() + 5 |
| 727 | while not phone.asks and time.time() < deadline: |
| 728 | phone.poll() |
| 729 | time.sleep(0.05) |
| 730 | t.join(5) |
| 731 | assert phone.asks and phone.asks[0]["tree"] == "gamma" and phone.asks[0]["inputs"] == {"text": "hi there"} |
| 732 | out = got["out"] |
| 733 | assert out["status"] == "done" and out["say"] == "gamma, from the phone" |
| 734 | assert out["peer"] == phone.id and out["via"] == ["member"] and out["output"] == {"inputs": {"text": "hi there"}} |
| 735 | assert [s["kind"] for s in out["trace"]["steps"]] == ["intent", "done"] and out["trace"]["steps"][0]["next"] == "say" |
| 736 | # A tree the hub runs itself stays on the hub, even though the phone holds it too. |
| 737 | _, out = http_json("POST", f"{srv.url}/intent", {"text": "alpha hi"}, token="tok") |
| 738 | assert out["status"] == "done" and out["peer"] == "local" |
| 739 | # An answer nobody waits for, or from the wrong member, lands nowhere. |
| 740 | assert phone.post({"type": "Answer", "object": {"run": "nope", "outcome": {"status": "done"}}})[0] == 404 |
| 741 | fed.ask_timeout = 0.2 |
| 742 | pending = fed.ask(fed.followers[phone.id], "gamma", {"text": "x"}, timeout=0.5) # the phone is not polling |
| 743 | assert pending["status"] == "error" and pending["reason"] == "member-timeout" and pending["tree"] == "gamma" |
| 744 | phone.answers = {} |
| 745 | _, out = http_json("POST", f"{srv.url}/run", {"tree": "gamma", "inputs": {"text": "x"}}, token="tok") |
| 746 | assert out["reason"] == "member-timeout" and out["peer"] == phone.id |
| 747 | # The Ask is still in the mailbox; an Answer arriving late is refused, not misfiled. |
| 748 | acts = phone.poll() |
| 749 | asks = [a for a in acts if a["type"] == "Ask"] |
| 750 | assert len(asks) == 2 |
| 751 | assert phone.post({"type": "Answer", "object": {"run": asks[-1]["object"]["run"], "outcome": {"status": "done"}}})[0] == 404 |
| 752 | # Leaving forgets what was handed out; joining again hands it out again. |
| 753 | assert phone.post({"type": "Undo", "object": {"type": "Follow", "actor": phone.id, "object": srv.url}})[0] == 200 |
| 754 | assert phone.id not in fed._handed |
| 755 | assert phone.follow(token="tok")[0] == 202 and [a["type"] for a in phone.poll()].count("Keep") == 2 |
| 756 | # Trust survives a restart of the hub's state. |
| 757 | assert fed.followers[phone.id].to_dict()["trusted"] is True |
| 758 | finally: |
| 759 | hub.stop() |
| 760 | srv.stop() |
| 761 | |
| 762 | |
| 763 | GAPPY = ''' |
| 764 | name = "gappy" |
| 765 | version = 2 |
| 766 | namespace = "pyrtan" |
| 767 | description = "a tree with a decide that covers one value" |
| 768 | start = "go" |
| 769 | |
| 770 | [inputs.v] |
| 771 | type = "int" |
| 772 | |
| 773 | [nodes.go] |
| 774 | kind = "set" |
| 775 | set = { x = "{{ inputs.v }}" } |
| 776 | next = "pick" |
| 777 | |
| 778 | [nodes.pick] |
| 779 | kind = "decide" |
| 780 | on = "x" |
| 781 | branches = [{ when = { eq = 1 }, then = "one" }] |
| 782 | |
| 783 | [nodes.one] |
| 784 | kind = "done" |
| 785 | say = "one" |
| 786 | ''' |
| 787 | |
| 788 | |
| 789 | def test_gap_reports_travel_with_announces_when_a_hub_opts_in(tmp_path): |
| 790 | (tmp_path / "g").mkdir() |
| 791 | (tmp_path / "g" / "gappy.toml").write_text(GAPPY) |
| 792 | (tmp_path / "g" / "plain.toml").write_text(ECHO.format(name="plain", word="plain")) |
| 793 | from pyrtan.hub import serve_hub |
| 794 | |
| 795 | a_hub, a_srv = serve_hub(port=0, quiet=True, name="reporter", trees=[tmp_path / "g"], federate=True, report_gaps=True, poll_every=60) |
| 796 | b_hub, b_srv = serve_hub(port=0, quiet=True, name="carrier", federate=True, poll_every=60, state=str(tmp_path / "b.json")) |
| 797 | try: |
| 798 | # Two gaps on the catalog tree (namespace + version), seen twice and once. |
| 799 | for v in (7, 7, 9): |
| 800 | out = http_json("POST", f"{a_srv.url}/run", {"tree": "gappy", "inputs": {"v": v}})[1] |
| 801 | assert out["reason"] == "gap" |
| 802 | own = sorted(a_hub.gap_reports(), key=lambda r: r["observed"]) |
| 803 | assert [(r["id"], r["node"], r["on"], r["observed"], r["count"]) for r in own] == [("pyrtan/gappy@2", "pick", "state.x", 7, 2), ("pyrtan/gappy@2", "pick", "state.x", 9, 1)] |
| 804 | _, view = http_json("GET", f"{a_srv.url}/reports") # public, like /directory |
| 805 | assert view["reporting"] is True and view["count"] == 2 and view["reports"][0] == {"id": "pyrtan/gappy@2", "node": "pick", "on": "state.x", "observed": 7, "count": 2, "reporters": 1, "last": view["reports"][0]["last"]} |
| 806 | # B follows A: A's announce carries the reports; B keeps them, says who reported, and passes them on. |
| 807 | assert http_json("POST", f"{b_srv.url}/follow", {"url": a_srv.url})[0] == 200 |
| 808 | _, bview = http_json("GET", f"{b_srv.url}/reports") |
| 809 | assert bview["reporting"] is False and bview["count"] == 2 and bview["reports"][0]["reporters"] == 1 |
| 810 | assert {r["reporter"] for r in b_hub.federation.reports.values()} == {a_srv.url} |
| 811 | assert any(r["id"] == "pyrtan/gappy@2" for r in b_hub.federation.report_gossip()) |
| 812 | # The same gap reported by a second hub counts that hub too; B's own announce (not opted in) carries only what it heard. |
| 813 | b_hub.federation.merge_reports([{"id": "pyrtan/gappy@2", "node": "pick", "on": "state.x", "observed": 7, "count": 5, "last": time.time()}], via="http://other.example") |
| 814 | row = next(r for r in http_json("GET", f"{b_srv.url}/reports")[1]["reports"] if r["observed"] == 7) |
| 815 | assert row["count"] == 7 and row["reporters"] == 2 |
| 816 | # Shaping: never an input, no id outside namespace/name@version, no future, no old news, long values summarised. |
| 817 | now = time.time() |
| 818 | assert b_hub.federation.merge_reports([{"id": "gappy", "node": "pick", "on": "x", "observed": 1}, {"id": "pyrtan/gappy@2", "node": "pick", "on": "x", "observed": 1, "last": now - 60 * 86400}, "junk"], via="x") == 0 |
| 819 | assert b_hub.federation.merge_reports([{"id": "a/b@1", "node": "n", "on": "o", "observed": "v" * 200, "last": now + 1e6, "count": "3"}], via="http://x") == 1 |
| 820 | shaped = next(r for r in b_hub.federation.reports.values() if r["id"] == "a/b@1") |
| 821 | assert shaped["observed"] == "<str>" and shaped["count"] == 3 and shaped["last"] <= time.time() and shaped["reporter"] == "http://x" |
| 822 | assert b_hub.federation.merge_reports([{"id": "a/b@1", "node": "n", "on": "o", "observed": {"k": 1}, "reporter": "https://r.example/"}], via="http://x") == 1 |
| 823 | assert any(r["reporter"] == "https://r.example" and r["observed"] == "<dict>" for r in b_hub.federation.reports.values()) |
| 824 | # Reports survive a restart through the state file. |
| 825 | b_hub.federation._save() |
| 826 | again = Federation(b_srv.url, state=str(tmp_path / "b.json")) |
| 827 | assert {r["id"] for r in again.reports.values()} == {"pyrtan/gappy@2", "a/b@1"} |
| 828 | # A tree without a namespace reports nothing. |
| 829 | assert not any(r["id"].endswith("/plain@1") for r in a_hub.gap_reports()) |
| 830 | finally: |
| 831 | a_hub.stop(); a_srv.stop(); b_hub.stop(); b_srv.stop() |
| 832 | |
| 833 | |
| 834 | def test_reports_command_and_snapshot(two, tmp_path, capsys, monkeypatch): |
| 835 | a, b = two |
| 836 | a.fed.report_gaps = True |
| 837 | a.fed.own_reports = lambda: [{"id": "pyrtan/weather@1", "node": "code", "on": "state.code", "observed": 99, "count": 4, "last": time.time()}] |
| 838 | assert main(["reports", "--at", a.url]) == 0 |
| 839 | assert "pyrtan/weather@1 code: state.code = 99 ×4" in capsys.readouterr().out |
| 840 | assert main(["reports", "nobody/x", "--at", a.url]) == 0 and "no gap reports" in capsys.readouterr().out |
| 841 | assert main(["reports", "--at", a.url, "--json"]) == 0 and json.loads(capsys.readouterr().out)["reports"][0]["id"] == "pyrtan/weather@1" |
| 842 | # The site's snapshot asks the listed hubs for /reports and bakes reports.json; the command reads that when no hub is given. |
| 843 | import subprocess, sys as _sys |
| 844 | |
| 845 | listed = tmp_path / "hubs.json" |
| 846 | listed.write_text(json.dumps({"hubs": [a.url]})) |
| 847 | out = subprocess.run([_sys.executable, "site/snapshot.py", str(listed), str(tmp_path / "directory.json"), "--reports", str(tmp_path / "reports.json")], capture_output=True, text=True, cwd=str(ROOT)) |
| 848 | assert out.returncode == 0, out.stderr |
| 849 | baked = json.loads((tmp_path / "reports.json").read_text()) |
| 850 | assert baked["count"] == 1 and baked["reports"][0]["id"] == "pyrtan/weather@1" and baked["reports"][0]["count"] == 4 |
| 851 | monkeypatch.setenv("PYRTAN_REPORTS", str(tmp_path / "reports.json")) |
| 852 | monkeypatch.delenv("PYRTAN_AT", raising=False) |
| 853 | assert main(["reports"]) == 0 and "pyrtan/weather@1 code" in capsys.readouterr().out |
| 854 | |
| 855 | |
| 856 | # ---------------------------------------------------------------- networks |
| 857 | |
| 858 | |
| 859 | def test_invites_and_network_ids(): |
| 860 | from pyrtan.federation import Network, network_id, parse_invite |
| 861 | |
| 862 | net, hub = parse_invite("home:abcdefghijklmnop1234@https://nas.example.ts.net/") |
| 863 | assert (net.name, net.secret, hub) == ("home", "abcdefghijklmnop1234", "https://nas.example.ts.net") |
| 864 | assert parse_invite("home:abcdefghijklmnop1234")[1] is None |
| 865 | assert net.invite("https://x/") == "home:abcdefghijklmnop1234@https://x" and net.invite() == "home:abcdefghijklmnop1234" |
| 866 | for bad in ("home", "Home:abcdefghijklmnop1234", "home:short", "home:abcdefghijklmnop1234@ftp://x", ""): |
| 867 | with pytest.raises(ValueError): |
| 868 | parse_invite(bad) |
| 869 | # The id comes from name and secret: same name, other secret, another network; and it holds neither. |
| 870 | assert network_id("home", "a" * 20) != network_id("home", "b" * 20) == Network("home", "b" * 20).id |
| 871 | assert "a" * 20 not in network_id("home", "a" * 20) |
| 872 | |
| 873 | |
| 874 | def test_a_hub_starts_a_network_and_others_join_by_invite(tmp_path): |
| 875 | from pyrtan.federation import Network |
| 876 | |
| 877 | for n in "abc": |
| 878 | (tmp_path / n).mkdir() |
| 879 | (tmp_path / n / f"{n}x.toml").write_text(ECHO.format(name=f"{n}x", word=f"{n}x")) |
| 880 | a = Swarm("a", tmp_path / "a", token="token-a", open_follows=False) # takes no follows outside its networks |
| 881 | b = Swarm("b", tmp_path / "b", token="token-b") |
| 882 | c = Swarm("c", tmp_path / "c") |
| 883 | try: |
| 884 | status, made = a.post("/networks", {"name": "home"}) |
| 885 | assert status == 200 and made["network"] == "home" and made["invite"].startswith("home:") and made["invite"].endswith(f"@{a.url}") |
| 886 | assert a.post("/networks", {"name": "home"})[0] == 400 and a.post("/networks", {"name": "Not OK"})[0] == 400 |
| 887 | assert a.fed.following == {} and a.fed.followers == {} # a network of one |
| 888 | # B joins by invite: it is in home, follows A in it, and routes to A's trees. |
| 889 | status, joined = b.post("/join", {"invite": made["invite"]}) |
| 890 | assert status == 200 and joined["network"] == "home" and joined["following"]["url"] == a.url and joined["following"]["state"] == "accepted" |
| 891 | assert b.fed.following[a.url].network == a.fed.networks["home"].id == a.fed.followers[b.url].network |
| 892 | assert b.post("/intent", {"text": "ax hi"})[1]["say"] == "ax: hi" |
| 893 | # Outside the network A takes nobody (it is --closed); in another network of the same name, nobody either. |
| 894 | assert c.fed.follow(a.url).state == "pending" and "closed" in c.fed.following[a.url].last_error |
| 895 | c.fed.add_network(Network("home", "another-secret-entirely")) |
| 896 | assert c.fed.follow(a.url, network="home").state == "pending" |
| 897 | assert "not-in-network" in c.fed.following[a.url].last_error |
| 898 | # A Follow naming home's id but signed with another secret is refused as unsigned. |
| 899 | forged = {"type": "Follow", "id": "x", "actor": c.url, "object": a.url, "secret": "p" * 32, "inbox": f"{c.url}/inbox", "network": a.fed.networks["home"].id} |
| 900 | status, answer = http_json("POST", f"{a.url}/inbox", forged, headers=sign("another-secret-entirely", c.url, "POST", "/inbox", forged)) |
| 901 | assert status == 401 and answer["reason"] == "unsigned" |
| 902 | # The invite lets C in, and joining again changes nothing; a name taken by another network is refused. |
| 903 | assert c.post("/leave", {"network": "home"})[1]["left"] is True and c.url not in a.fed.followers |
| 904 | status, joined = c.post("/join", {"invite": made["invite"]}) |
| 905 | assert status == 200 and joined["following"]["state"] == "accepted" |
| 906 | assert c.post("/join", {"invite": made["invite"]})[1]["following"]["url"] == a.url |
| 907 | assert c.post("/join", {"invite": "home:yet-another-secret-xyz"})[0] == 400 |
| 908 | assert c.post("/join", {"invite": "nonsense"})[1]["reason"] == "bad-invite" |
| 909 | # An invite with no hub: in the network, following nobody. |
| 910 | status, alone = b.post("/join", {"invite": "solo:abcdefghijklmnopqrstu"}) |
| 911 | assert status == 200 and alone["following"] is None and "network of one" in alone["say"] |
| 912 | _, view = b.get("/networks") |
| 913 | assert {n["name"]: n["following"] for n in view["networks"]} == {"home": [a.url], "solo": []} |
| 914 | assert all(n["invite"].endswith(f"@{b.url}") for n in view["networks"]) |
| 915 | assert http_json("GET", f"{b.url}/networks")[0] == 401 # the invites are the operator's |
| 916 | # Leaving cuts the links made in it, both ways, and forgets it. |
| 917 | assert b.post("/leave", {"network": "home"})[1]["left"] is True |
| 918 | assert a.url not in b.fed.following and b.url not in a.fed.followers and "home" not in b.fed.networks |
| 919 | assert b.post("/leave", {"network": "home"})[1]["left"] is False |
| 920 | finally: |
| 921 | for s in (a, b, c): |
| 922 | s.stop() |
| 923 | |
| 924 | |
| 925 | def test_a_private_network_never_reaches_the_public_directory(tmp_path): |
| 926 | from pyrtan.federation import Network |
| 927 | |
| 928 | for n in "abc": |
| 929 | (tmp_path / n).mkdir() |
| 930 | (tmp_path / n / f"{n}x.toml").write_text(ECHO.format(name=f"{n}x", word=f"{n}x")) |
| 931 | public = Network("pyrtan", "public-default-network-of-pyrtan-com", public=True) |
| 932 | a = Swarm("a", tmp_path / "a", listed=True) |
| 933 | b = Swarm("b", tmp_path / "b", token="token-b") |
| 934 | c = Swarm("c", tmp_path / "c", listed=True) |
| 935 | try: |
| 936 | # A is in the public network; B joins it through A. C starts "home", and B joins that too. |
| 937 | a.fed.add_network(public) |
| 938 | assert b.fed.join(Network(public.name, public.secret, public=True), [a.url]).state == "accepted" |
| 939 | home = c.fed.create_network("home") |
| 940 | assert b.fed.join(Network("home", home.secret), [c.url]).state == "accepted" |
| 941 | b.hub.refresh_all() |
| 942 | # B hears A in the public network and C in home, and passes each on only within its own network. |
| 943 | heard = {e["id"]: e["network"] for e in b.fed.directory_entries.values()} |
| 944 | assert heard == {a.url: public.id, c.url: home.id} |
| 945 | assert c.url not in {h["id"] for h in a.fed.directory()["hubs"]} |
| 946 | assert a.url not in {h["id"] for h in c.fed.directory()["hubs"]} |
| 947 | assert [e["id"] for e in b.fed.gossip(public.id)] == [a.url] and [e["id"] for e in b.fed.gossip(home.id)] == [c.url] |
| 948 | # B's public /directory shows the public network; the operator also sees home. |
| 949 | _, shown = http_json("GET", f"{b.url}/directory") |
| 950 | assert {h["id"] for h in shown["hubs"]} == {a.url} and shown["networks"] == {public.id: "pyrtan"} |
| 951 | _, full = b.get("/directory") |
| 952 | assert {h["id"]: h["networks"] for h in full["hubs"]} == {a.url: [public.id], c.url: [home.id]} |
| 953 | assert full["networks"] == {public.id: "pyrtan", home.id: "home"} |
| 954 | # The directory blob B's keepers hold is the public part only. |
| 955 | assert {e["id"] for e in b.fed.directory_backup()} == {a.url} |
| 956 | # State keeps networks and the tags; an entry of a network since left is not taken back. |
| 957 | state = tmp_path / "b-state.json" |
| 958 | b.fed.state_path = state |
| 959 | b.fed._save() |
| 960 | again = Federation(b.url, state=state) |
| 961 | assert set(again.networks) == {"pyrtan", "home"} and again.networks["pyrtan"].public |
| 962 | assert {e["id"]: e["network"] for e in again.directory_entries.values()} == {a.url: public.id, c.url: home.id} |
| 963 | stale = json.loads(state.read_text()) |
| 964 | stale["networks"] = [n for n in stale["networks"] if n["name"] != "home"] |
| 965 | state.write_text(json.dumps(stale)) |
| 966 | assert {e["id"] for e in Federation(b.url, state=state).directory_entries.values()} == {a.url} |
| 967 | finally: |
| 968 | for s in (a, b, c): |
| 969 | s.stop() |
| 970 | |
| 971 | |
| 972 | def test_the_default_network_from_pyrtan_com(two, tmp_path, monkeypatch, capsys): |
| 973 | import subprocess |
| 974 | import sys as _sys |
| 975 | |
| 976 | from pyrtan.federation import default_network |
| 977 | from pyrtan.hub import serve_hub |
| 978 | |
| 979 | a, b = two |
| 980 | |
| 981 | def run(*argv): |
| 982 | code = main(list(argv)) |
| 983 | out = capsys.readouterr() |
| 984 | return code, out.out, out.err |
| 985 | |
| 986 | network = ROOT / "site" / "rendezvous" / "network.json" |
| 987 | monkeypatch.setenv("PYRTAN_NETWORK", str(network)) |
| 988 | listed = tmp_path / "hubs.json" |
| 989 | listed.write_text(json.dumps({"hubs": ["http://127.0.0.1:9", b.url]})) |
| 990 | monkeypatch.setenv("PYRTAN_HUB_LIST", str(listed)) |
| 991 | monkeypatch.setenv("PYRTAN_SNAPSHOT", str(tmp_path / "no-snapshot.json")) |
| 992 | monkeypatch.delenv("PYRTAN_RENDEZVOUS", raising=False) |
| 993 | default = default_network() |
| 994 | assert default is not None and default.name == "pyrtan" and default.public |
| 995 | # The first hub in the default network is a network of one: nobody to follow yet. |
| 996 | code, out, _ = run("join", "--at", b.url, "--token", "token-b", "--listed") |
| 997 | assert code == 0 and "in pyrtan; no hub in it accepted a follow yet" in out and b.fed.following == {} |
| 998 | # `pyrtan follow` with no URL is `pyrtan join`: A is in the default network, following the listed hub in it. |
| 999 | code, out, _ = run("follow", "--at", a.url, "--token", "token-a") |
| 1000 | assert code == 0 and f"in pyrtan, following {b.url}" in out and "invite others" not in out # a public network needs no invite |
| 1001 | assert a.fed.following[b.url].network == default.id |
| 1002 | code, out, _ = run("network", "list", "--at", a.url, "--token", "token-a") |
| 1003 | assert code == 0 and f"pyrtan {default.id} public following 1" in out |
| 1004 | # Its own network, by command: the invite, then another hub joins with it. |
| 1005 | code, out, _ = run("network", "new", "home", "--at", b.url, "--token", "token-b") |
| 1006 | invite = next(line.split("pyrtan join ", 1)[1] for line in out.splitlines() if "pyrtan join " in line) |
| 1007 | assert code == 0 and invite.startswith("home:") and invite.endswith(f"@{b.url}") |
| 1008 | code, out, _ = run("join", invite, "--at", a.url, "--token", "token-a") |
| 1009 | assert code == 0 and "in home" in out and a.fed.following[b.url].network == b.fed.networks["home"].id |
| 1010 | assert run("follow", "--at", a.url, "--token", "token-a", "--network", "home")[0] == 2 # a network needs a hub to follow in it |
| 1011 | code, out, _ = run("network", "leave", "home", "--at", a.url, "--token", "token-a") |
| 1012 | assert code == 0 and "left home" in out and "home" not in a.fed.networks |
| 1013 | assert run("network", "new", "--at", a.url, "--token", "token-a")[0] == 2 |
| 1014 | # A hub started with --join default is in it from the start. |
| 1015 | hub, srv = serve_hub(port=0, quiet=True, name="d", join=["default"]) |
| 1016 | try: |
| 1017 | assert hub.federation is not None and hub.federation.following[b.url].network == default.id |
| 1018 | finally: |
| 1019 | hub.stop() |
| 1020 | srv.stop() |
| 1021 | # The snapshot keeps the default network's part of each directory and nobody else's. |
| 1022 | b.fed.merge_directory([{"id": "http://private.example", "name": "p", "trees": [], "seen": time.time()}], via="x", network="0123456789abcdef") |
| 1023 | b.fed.merge_directory([{"id": "http://public.example", "name": "q", "trees": [], "seen": time.time()}], via="x", network=default.id) |
| 1024 | b.hub.token = None # as anyone sees it, the private part included: the snapshot must still drop it |
| 1025 | out = tmp_path / "directory.json" |
| 1026 | done = subprocess.run([_sys.executable, "site/snapshot.py", str(listed), str(out), "--network", str(network)], capture_output=True, text=True, cwd=str(ROOT)) |
| 1027 | assert done.returncode == 0, done.stderr |
| 1028 | ids = {h["id"] for h in json.loads(out.read_text())["hubs"]} |
| 1029 | assert "http://public.example" in ids and "http://private.example" not in ids and b.url in ids |