aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_root_ops_reach_the_live_set.py
blob: d45a5b2bb054fe1177c36c0283d43426cd81ee65 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
"""
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()