summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_index_delta_carries_roots.py
blob: 227f2d0a04c650719149c83e788aaaffc45bf348 (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
"""
The message that says something changed has to be able to say what.

A group's directory table travelled on `index_sync` alone — a *full* index,
which the node only ever sends on request. Every ongoing change went out as an
`index_delta`, which carried files and nothing else. So a root added, removed,
ejected or plugged by the operator reached every other client's screen only
when somebody happened to reload the page.

It was hidden by the acks: `root_add_ack` and friends broadcast the new table
to whoever was connected, so the common cases looked fine. What that could not
cover is a client connecting mid-change, one whose ack was lost, or — the one
that surfaced it — the operator's own client, where the ack landed and was then
overwritten by an index fetched before the node had rebuilt anything.

Additive on the wire (MNP 1.1): a 1.0 client sees a field it does not read.
"""

from pathlib import Path

from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

from meshbay_common.crypto import generate_gek
from meshbay_common.groupbox import PURPOSE_INDEX, unseal
from meshbay_node.indexer.group_index import GroupIndex
from meshbay_node.roots import RootSet
from meshbay_node.transport.wire import index_delta_message, index_sync_message


GROUP = "g" * 32


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},
    ])


def _index() -> GroupIndex:
    return GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate(),
                      gek=generate_gek())


def _payload(msg: dict, index: GroupIndex) -> dict:
    """What a member actually reads, through the seal rather than around it."""
    return unseal(index.gek, PURPOSE_INDEX, msg["type"], GROUP, msg)


class _Delta:
    base_version = 1
    version = 2
    additions: list = []
    deletions: list = []
    updates: list = []


def test_a_delta_carries_the_directory_table(tmp_path):
    index = _index()
    msg = index_delta_message(index, _Delta(), _roots(tmp_path))
    payload = _payload(msg, index)

    assert [r["name"] for r in payload["roots"]] == ["Films", "Albums"]
    assert payload["roots"][0]["writable"] is True
    assert payload["roots"][1]["removable"] is True


def test_the_table_is_sealed_with_the_rest(tmp_path):
    """
    It is group content, not routing. Only `type`, `v` and `group_id` stay in
    clear, because a receiver has to route and authenticate before it would
    trust a decryption.
    """
    index = _index()
    msg = index_delta_message(index, _Delta(), _roots(tmp_path))
    assert set(msg) - {"type", "v", "group_id"}, "nothing was sealed"
    assert "roots" not in msg, "the directory table is outside the envelope"


def test_a_delta_still_works_without_a_table(tmp_path):
    """
    The argument is optional, so an older caller — or a path that has no root
    set to hand — produces a message a client reads exactly as before.
    """
    index = _index()
    payload = _payload(index_delta_message(index, _Delta()), index)
    assert "roots" not in payload
    assert payload["version"] == 2


def test_the_table_says_the_same_thing_on_both_messages(tmp_path):
    """
    Two encodings of one idea is the drift `wire.py` exists to prevent — it
    already happened twice, for `index_sync` and for `file_chunk`.
    """
    index = _index()
    roots = _roots(tmp_path)
    delta = _payload(index_delta_message(index, _Delta(), roots), index)
    sync = _payload(index_sync_message(index, roots), index)
    assert delta["roots"] == sync["roots"]


def test_the_table_never_carries_a_path(tmp_path):
    """
    This message goes to every member. Where a directory lives on the
    operator's disk is theirs — see test_root_paths_are_operator_only.py.
    """
    index = _index()
    payload = _payload(index_delta_message(index, _Delta(), _roots(tmp_path)),
                       index)
    assert not any("path" in r for r in payload["roots"])


def test_an_ejected_root_is_visible_in_the_delta(tmp_path):
    """
    The case this was written for. An eject changes no file — the entries
    freeze — so the delta it produces is empty of additions, deletions and
    updates. Without the table it says literally nothing, which is how a
    library disappearing from under the group's feet went unannounced.
    """
    index = _index()
    roots = _roots(tmp_path)
    roots.roots[1].ejected = True
    roots.roots[1].available = False

    payload = _payload(index_delta_message(index, _Delta(), roots), index)
    assert payload["additions"] == [] and payload["deletions"] == []
    assert payload["roots"][1]["ejected"] is True
    assert payload["roots"][1]["available"] is False