aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_member_removal.py
blob: 7af73a01f62db84afd2347183ff53f1b9a51e9c1 (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
"""
Removing a member is two halves, and one must not be able to cancel the other.

Source-level, for the reason `test_spa_ordering.py` gives at more length: no
other test in this tree can see inside a callback in the SPA, and this class of
defect reached a live browser. The node half and the hub half each remove a
different kind of access — the group key, and the ability to reach the node at
all — and the node's refusal used to `throw` out of the whole callback, so
somebody invited to the wrong group could not be removed from anywhere: the
node holds no member row for a code nobody has redeemed, answered "no such
member in that group", and the hub row survived every attempt while the list
(which the hub answers) went on showing them.

If `removeMember` is restructured, check the invariant still holds — a failing
node call still reaches the hub call — and then move the markers.
"""

from pathlib import Path

import pytest

SETTINGS = (Path(__file__).resolve().parents[1]
            / "src" / "meshbay_hub" / "static" / "group-settings.js")

pytestmark = pytest.mark.skipif(
    not SETTINGS.exists(), reason="SPA sources not present")


def _remove_member_body() -> str:
    source = SETTINGS.read_text()
    start = source.find("const removeMember = useCallback(")
    assert start != -1, "removeMember is gone from group-settings.js"
    end = source.find("\n  }, [", start)
    assert end != -1, "removeMember's dependency list is gone — update this test"
    return source[start:end]


def test_the_hub_half_runs_even_when_the_node_refuses():
    body = _remove_member_body()
    node_call = body.find("transport.revokeMember(")
    hub_call = body.find("method: 'DELETE'")
    assert node_call != -1, "the node is no longer asked to revoke"
    assert hub_call != -1, "the hub membership is no longer deleted"
    assert node_call < hub_call, (
        "the hub removal now runs first — a failure between the two would "
        "leave them able to reach a node that still serves them")
    # What the fix is: the node call carries its own catch, so the statements
    # after it run whatever the node answered. Anything else — no catch, or one
    # that only closes after the hub call — is the shape that shipped.
    caught = body.find("catch", node_call)
    assert caught != -1 and caught < hub_call, (
        "a node refusal escapes past the hub removal: the person stays a "
        "member on the hub, in this list and in every other session")


def test_removing_from_one_group_does_not_unpin_the_account():
    """
    `unpin` takes no group. `roster.unpin` deletes the identity and *every*
    member row the account holds on this node, and `ops.unpin_member` drops the
    stored keypair bundle with them — so calling it from a per-group removal
    took the person out of every other group on the node as well. Silently:
    the loopback path writes no audit entry, so the node's journal showed a
    clean join and then, hours later, a refusal with nothing in between. And
    unrecoverably for anyone whose invitation was already spent, since the way
    back starts with a code the operator has to issue again.

    Forgetting a pinned key is a separate operator decision with its own button
    on the node page. If one is ever wanted here, it has to be scoped to the
    group first.
    """
    body = _remove_member_body()
    assert "/unpin" not in body, (
        "a per-group removal unpins the account node-wide: every other group "
        "this person holds on this node goes with it")


def test_a_refusal_is_still_reported():
    """
    Swallowing it would be the opposite mistake — the group key is what the
    node half withdraws, and an operator who is not told it failed believes
    they have taken it back.
    """
    body = _remove_member_body()
    assert "nodeError" in body and "setError(nodeError" in body, (
        "the node's refusal is discarded; removal would report success while "
        "the node goes on serving them the group key")