""" A root operation changes what the node is *serving*, not only what it will serve after a restart. Every root op writes two places: `node.toml`, which survives a restart, and the live `RootSet` in `groups_ctx[gid]["roots"]`, which is what the running node answers from. The index payload is built from the second, so an op that updates only the first is invisible until the daemon is restarted — and worse than invisible, because the ack it sends *does* carry the change, so the client shows it for a moment and the next `index_sync` takes it away again. `add_root` was like that. It appended to the config and to node.toml, and `_retarget_indexer` then re-pointed the indexer at a `RootSet` object nobody had touched — retargeting it at exactly what it already had. Found by an operator adding a directory, seeing nothing, and being told on the second attempt that its name collided with itself. The loopback API hid it: `ui/app.py` fires `reload_fn()` after the op, which re-reads node.toml from disk. The MNP path does not, and the shared-directories table started offering Add over MNP in this refactor — a latent bug made reachable. """ from dataclasses import asdict from pathlib import Path from types import SimpleNamespace import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from meshbay_node import ops from meshbay_node.config import GroupConfig, NodeConfig, RootSpec from meshbay_node.indexer.group_index import GroupIndex from meshbay_node.roots import RootSet from meshbay_node.roster import Roster pytestmark = pytest.mark.asyncio GROUP = "g" * 32 async def _state(tmp_path: Path) -> tuple[dict, Roster]: """A node hosting one group with two roots, as node.toml and as live state.""" for name in ("one", "two"): (tmp_path / name).mkdir() cfg = GroupConfig(id=GROUP, name="plop", roots=[ RootSpec(path=str(tmp_path / "one"), name="one"), RootSpec(path=str(tmp_path / "two"), name="two"), ]) node_cfg = NodeConfig.__new__(NodeConfig) node_cfg.groups = [cfg] conf = tmp_path / "node.toml" conf.write_text( f'[[groups]]\nid = "{GROUP}"\nname = "plop"\n\n' f' [[groups.roots]]\n path = "{(tmp_path / "one").as_posix()}"\n' f' name = "one"\n\n' f' [[groups.roots]]\n path = "{(tmp_path / "two").as_posix()}"\n' f' name = "two"\n') live = RootSet.build([asdict(r) for r in cfg.roots]) index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate()) roster = Roster(db_path=tmp_path / "roster.db") await roster.open() state = { "config": node_cfg, "config_path": str(conf), "groups_ctx": {GROUP: {"index": index, "roots": live}}, "roster": roster, "node_user_id": "operator", } return state, roster def _live(state) -> RootSet: return state["groups_ctx"][GROUP]["roots"] # ── Adding ─────────────────────────────────────────────────────────────────── async def test_adding_a_root_reaches_the_running_node(tmp_path): state, roster = await _state(tmp_path) (tmp_path / "uploads").mkdir() try: result = await ops.add_root(state, GROUP, str(tmp_path / "uploads")) assert [r.name for r in _live(state)] == ["one", "two", "uploads"], ( "the live root set did not learn about the new directory, so the " "index will keep reporting the old one until a restart") assert [r["name"] for r in result["roots"]] == ["one", "two", "uploads"] finally: await roster.close() async def test_the_ack_describes_the_set_the_node_will_serve(tmp_path): """ Not a set built on the side. Describing something the node is not actually using is how the client shows a directory for one paint and loses it on the next index push — which reads as a UI bug and is not one. """ state, roster = await _state(tmp_path) (tmp_path / "uploads").mkdir() try: result = await ops.add_root(state, GROUP, str(tmp_path / "uploads")) assert result["roots"] == _live(state).describe() finally: await roster.close() async def test_adding_the_same_directory_twice_is_still_refused(tmp_path): """ The counter-property. The live set gaining the root must not make the collision check pass the second time — a group with the same path under two names indexes every file in it twice. """ state, roster = await _state(tmp_path) (tmp_path / "uploads").mkdir() try: await ops.add_root(state, GROUP, str(tmp_path / "uploads")) with pytest.raises(ops.OpError): await ops.add_root(state, GROUP, str(tmp_path / "uploads")) assert len(_live(state)) == 3, "the refused add left something behind" finally: await roster.close() async def test_a_second_different_root_still_lands(tmp_path): state, roster = await _state(tmp_path) (tmp_path / "uploads").mkdir() (tmp_path / "incoming").mkdir() try: await ops.add_root(state, GROUP, str(tmp_path / "uploads")) await ops.add_root(state, GROUP, str(tmp_path / "incoming"), writable=True) assert [r.name for r in _live(state)] == [ "one", "two", "uploads", "incoming"] assert _live(state).by_name("incoming").writable is True finally: await roster.close() # ── The other two, which already did this ──────────────────────────────────── async def test_removing_a_root_reaches_the_running_node(tmp_path): state, roster = await _state(tmp_path) try: result = await ops.remove_root(state, GROUP, "two") assert [r.name for r in _live(state)] == ["one"] assert result["roots"] == _live(state).describe() finally: await roster.close() async def test_updating_a_root_reaches_the_running_node(tmp_path): state, roster = await _state(tmp_path) state["config"] = SimpleNamespace(groups=state["config"].groups) try: result = await ops.update_root(state, GROUP, "two", writable=True, removable=True) live = _live(state).by_name("two") assert live.writable is True and live.removable is True assert result["roots"] == _live(state).describe() finally: await roster.close() # ── And node.toml, so a restart agrees with the running node ───────────────── async def test_the_config_file_and_the_live_set_say_the_same_thing(tmp_path): """ The two halves must not drift: what the node serves now and what it will serve after a restart are the same answer, or the operator's next restart silently undoes their last change. """ state, roster = await _state(tmp_path) (tmp_path / "uploads").mkdir() try: await ops.add_root(state, GROUP, str(tmp_path / "uploads"), writable=True) await ops.remove_root(state, GROUP, "one") from_disk = RootSet.build([ asdict(r) for r in state["config"].groups[0].roots]) assert ([r.name for r in from_disk] == [r.name for r in _live(state)]) text = Path(state["config_path"]).read_text() assert text.count("[[groups.roots]]") == 2 assert "uploads" in text finally: await roster.close()