aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_ops.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/tests/test_ops.py')
-rw-r--r--packages/meshbay-node/tests/test_ops.py179
1 files changed, 179 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_ops.py b/packages/meshbay-node/tests/test_ops.py
new file mode 100644
index 0000000..f7fd259
--- /dev/null
+++ b/packages/meshbay-node/tests/test_ops.py
@@ -0,0 +1,179 @@
+"""
+One implementation, several front doors.
+
+The point of `meshbay_node.ops` is not tidiness. C1 and C6 were both "a second
+path into the node with its own weaker handshake", and two implementations of
+`revoke` with two authorization checks is that shape one size down. So the tests
+that matter here are the ones that would fail if a second implementation
+appeared: the adapters must be thin, and the operations must not decide who may
+call them.
+"""
+
+import inspect
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from conftest import one_root
+from meshbay_node import ops
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.transport.quic_server import Denylist
+
+
+
+def _state(tmp_path: Path) -> dict:
+ shared = tmp_path / "shared"
+ shared.mkdir(exist_ok=True)
+ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
+ return {
+ "groups_ctx": {"g" * 32: {"index": index, "roots": one_root(shared),
+ "gek": None}},
+ "denylist": Denylist(path=tmp_path / "denylist.json"),
+ "indexes": {"g" * 32: index},
+ }
+
+
+# ── The adapters stay thin ───────────────────────────────────────────────────
+
+def test_operations_take_state_and_nothing_web_shaped():
+ """
+ An operation that knew about HTTP could not be called from MNP without a
+ second copy. Every public operation therefore takes `state` first and
+ returns plain data.
+ """
+ public = [(n, f) for n, f in vars(ops).items()
+ if inspect.iscoroutinefunction(f) and not n.startswith("_")]
+ assert public, "no operations found — did the module move?"
+ for name, fn in public:
+ params = list(inspect.signature(fn).parameters)
+ assert params and params[0] == "state", (
+ f"{name} does not take state first — an adapter would have to "
+ f"assemble something for it, which is where a second implementation "
+ f"begins")
+
+
+def test_the_http_adapter_adds_no_logic():
+ """
+ Each loopback handler should be a call into `ops` and nothing else. A
+ handler that grew a check of its own would be a rule the MNP path does not
+ have.
+ """
+ import meshbay_node.ui.app as ui
+ source = inspect.getsource(ui)
+ # Every endpoint that performs an operation routes through _op(...).
+ for endpoint in ("operator_pair", "create_invite", "revoke_member",
+ "unpin_member", "init_gek", "attach_group", "delete_file"):
+ start = source.index(f"async def {endpoint}(")
+ body = source[start:start + 700]
+ assert "_op(" in body.split("\n\n")[0] + body, (
+ f"{endpoint} does not go through the shared adapter")
+ assert "roster.set_status" not in body and "generate_gek" not in body, (
+ f"{endpoint} performs the operation itself instead of calling ops")
+
+
+def test_op_errors_carry_a_status_without_importing_http():
+ source = inspect.getsource(ops)
+ for forbidden in ("JSONResponse", "fastapi", "starlette", "HTTPException"):
+ assert forbidden not in source, (
+ f"ops imports {forbidden} — it must not know which adapter called it")
+
+
+# ── Denylist (14.10) ─────────────────────────────────────────────────────────
+
+async def test_denylist_reports_what_it_refuses(tmp_path):
+ state = _state(tmp_path)
+ state["denylist"].deny_user("alice")
+ state["denylist"].deny_group("g" * 32)
+
+ out = await ops.read_denylist(state)
+
+ assert out["users"] == ["alice"]
+ assert out["groups"] == ["g" * 32]
+ assert out["count"] == 2
+
+
+async def test_clearing_one_subject_leaves_the_rest(tmp_path):
+ state = _state(tmp_path)
+ state["denylist"].deny_user("alice")
+ state["denylist"].deny_user("bob")
+
+ out = await ops.clear_denylist(state, subject="alice")
+
+ assert out["removed"] == 1
+ assert (await ops.read_denylist(state))["users"] == ["bob"]
+
+
+async def test_clearing_everything_says_how_much(tmp_path):
+ """The count is the point: it tells the operator whether they undid one
+ revocation or all of them."""
+ state = _state(tmp_path)
+ for name in ("a", "b", "c"):
+ state["denylist"].deny_user(name)
+
+ out = await ops.clear_denylist(state)
+
+ assert out["removed"] == 3
+ assert (await ops.read_denylist(state))["count"] == 0
+
+
+async def test_denylist_survives_a_restart(tmp_path):
+ """Finding H4: revocations used to live only in memory, so a restart
+ silently un-revoked everyone."""
+ state = _state(tmp_path)
+ state["denylist"].deny_user("alice")
+
+ reopened = Denylist(path=tmp_path / "denylist.json")
+ assert reopened.is_denied("alice", jti="", group_id="")
+
+
+# ── File deletion (14.11) ────────────────────────────────────────────────────
+
+async def test_deleting_a_file_removes_it_from_disk_and_index(tmp_path):
+ state = _state(tmp_path)
+ ctx = state["groups_ctx"]["g" * 32]
+ target = ctx["roots"].roots[0].path / "gone.txt"
+ target.write_text("x")
+ from meshbay_common.protocol import IndexEntry
+ ctx["index"].add_entry(IndexEntry(id="a" * 64, name="gone.txt", path="shared",
+ size=1, type="other", added_at=0))
+
+ out = await ops.delete_file(state, "g" * 32, "a" * 64)
+
+ assert out["status"] == "deleted"
+ assert not target.exists()
+ assert ctx["index"].get_entry("a" * 64) is None
+
+
+async def test_deleting_from_an_unavailable_root_is_refused(tmp_path):
+ """
+ A frozen root's files are still listed. Deleting one would either fail
+ obscurely or — worse, once the drive returns — leave the index and the disk
+ disagreeing.
+ """
+ state = _state(tmp_path)
+ ctx = state["groups_ctx"]["g" * 32]
+ from meshbay_common.protocol import IndexEntry
+ ctx["index"].add_entry(IndexEntry(id="a" * 64, name="frozen.txt", path="shared",
+ size=1, type="other", added_at=0))
+ ctx["roots"].roots[0].available = False
+
+ with pytest.raises(ops.OpError, match="frozen, not gone"):
+ await ops.delete_file(state, "g" * 32, "a" * 64)
+
+ assert ctx["index"].get_entry("a" * 64) is not None
+
+
+async def test_deleting_an_unknown_file_says_so(tmp_path):
+ state = _state(tmp_path)
+ with pytest.raises(ops.OpError, match="No such file"):
+ await ops.delete_file(state, "g" * 32, "f" * 64)
+
+
+async def test_an_unhosted_group_offers_what_it_does_host(tmp_path):
+ """A bare "no such group" leaves an operator guessing at a UUID."""
+ state = _state(tmp_path)
+ with pytest.raises(ops.OpError) as exc:
+ await ops.delete_file(state, "z" * 32, "a" * 64)
+ assert exc.value.status == 404
+ assert exc.value.extra.get("available")