aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_root_paths_are_operator_only.py
blob: e00f1cff6a5548b282f9f5782d9d9b9df1e4f980 (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
"""
Where a directory lives on the operator's disk is theirs, not the group's.

`RootSet.describe()` feeds two very different audiences. The index payload goes
to every member, and has always deliberately carried no paths — a member is
told what exists and whether it is readable, not that the library sits in
`/media/<the operator's name>/BACKUP2`. The loopback API answers the operator
themselves, over a channel that already requires being on their machine with
the run token, where the path is exactly what they are asking for.

`meshbay-node root list` printed `?` for every directory because it read a
field the member form omits. Nothing caught it: the CLI reads a dict, the
payload is a dict, and neither end says what keys it owes the other.

Both halves matter and they pull opposite ways, so both are asserted here — a
test that only checked the operator gets paths would be satisfied by putting
them in the member payload too.
"""

import inspect
import re
import sys
from pathlib import Path

from meshbay_node import daemon as daemon_mod
from meshbay_node import ops
from meshbay_node.roots import RootSet

from conftest import patch_cli


def _roots(tmp_path: Path) -> RootSet:
    for name in ("Films", "Albums"):
        (tmp_path / name).mkdir()
    return RootSet.build([
        {"path": str(tmp_path / "Films"), "writable": True},
        {"path": str(tmp_path / "Albums"), "removable": True},
    ])


# ── The member's half ────────────────────────────────────────────────────────

def test_the_default_form_carries_no_path(tmp_path):
    described = _roots(tmp_path).describe()
    assert described, "no roots described"
    assert not any("path" in d for d in described), (
        "the index payload every member receives would carry the operator's "
        "filesystem layout")


def test_the_default_form_still_says_what_a_member_needs(tmp_path):
    """The counter-property: dropping the path must not drop the rest."""
    described = _roots(tmp_path).describe()
    for d in described:
        assert set(d) >= {"name", "kind", "available", "writable",
                          "removable", "ejected"}


def test_the_index_payload_is_built_without_paths():
    """
    Read from the source, because the alternative is asserting it about a
    payload built by a test rather than by the node.
    """
    from meshbay_node.indexer import indexer as indexer_mod
    source = inspect.getsource(indexer_mod)
    for call in re.findall(r"roots\.describe\([^)]*\)", source):
        assert "with_paths" not in call, (
            f"the indexer builds the member-facing roots table as {call} — "
            f"that payload goes to everyone in the group")


# ── The operator's half ──────────────────────────────────────────────────────

def test_the_operator_form_carries_the_path(tmp_path):
    described = _roots(tmp_path).describe(with_paths=True)
    assert all(d.get("path") for d in described)
    assert described[0]["path"] == str(tmp_path / "Films")


def test_the_loopback_api_asks_for_paths():
    """
    `list_groups` answers the operator's own channel, and the CLI's `root list`
    prints what it returns. Asking for the member form there is what printed a
    column of question marks.
    """
    source = inspect.getsource(ops.list_groups)
    assert "describe(with_paths=True)" in source, (
        "list_groups uses the member form, so every path it reports is missing")


def test_the_cli_only_reads_fields_the_payload_carries(monkeypatch, tmp_path, capsys):
    """
    The gap this whole file exists for. The CLI reads a dict and the API
    returns a dict; nothing between them says which keys are owed, so a name
    that is simply absent prints as a placeholder and looks like a node
    problem.

    `root list` is run against a payload that records every key it is asked
    for, so this holds wherever the CLI's code lives.
    """
    class _Any:
        path = Path("/tmp/x")
        name = "x"
        kind = "generic"
        writable = removable = ejected = False
        available = True

    offered = RootSet(roots=[_Any()]).describe(with_paths=True)[0]
    read: set[str] = set()

    class _Recording(dict):
        def __getitem__(self, key):
            read.add(key)
            return super().__getitem__(key)

        def get(self, key, default=None):
            read.add(key)
            return super().get(key, default)

    gid = "g" * 32
    patch_cli(monkeypatch, "_daemon_api", lambda cfg, path, **kw: {
        "groups": [{"id": gid, "roots": [_Recording(offered)]}]})
    patch_cli(monkeypatch, "_resolve_group", lambda cfg, g: gid)
    conf = tmp_path / "node.toml"
    conf.write_text('[hub]\nurl = "https://example.invalid"\n')
    patch_cli(monkeypatch, "DEFAULT_CONFIG_PATH", conf)
    # A stub missed would otherwise reach the node running on this machine.
    monkeypatch.setenv("HOME", str(tmp_path / "home"))
    monkeypatch.setattr(sys, "argv", ["meshbay-node", "root", "list"])

    daemon_mod.main()

    assert read, "the root CLI no longer reads the payload this way"
    assert "/tmp/x" in capsys.readouterr().out
    assert read <= set(offered), (
        f"the `root` CLI reads keys the loopback payload does not carry: "
        f"{sorted(read - set(offered))}")