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