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")
|