aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_unauthenticated_surface.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/test_unauthenticated_surface.py')
-rw-r--r--packages/meshbay-hub/tests/test_unauthenticated_surface.py134
1 files changed, 134 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_unauthenticated_surface.py b/packages/meshbay-hub/tests/test_unauthenticated_surface.py
new file mode 100644
index 0000000..2c9541a
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_unauthenticated_surface.py
@@ -0,0 +1,134 @@
+"""
+What the hub answers to somebody with no token.
+
+Every route either depends on an authentication dependency, or is on the list
+below with the reason it must not. The list is the review, written down: a new
+route that takes no account fails here until somebody adds it and says why,
+which is the moment the question "should this be public?" gets asked.
+
+Routes that authenticate in their own body (a signature, an MHP token, a JWT
+inside the first WebSocket message) are listed too, with what they check —
+"no `Depends`" is not the same as "open", and the list says which is which.
+"""
+
+import asyncio
+import json
+
+import pytest
+from fastapi.routing import APIRoute, APIWebSocketRoute
+
+AUTH_DEPENDENCIES = {
+ "_decode_token", "get_current_user", "require_user_scope",
+ "require_moderator", "require_admin",
+}
+
+# (method, path) → why it takes no hub account.
+PUBLIC = {
+ ("GET", "/"): "the application shell",
+ ("GET", "/app"): "the application shell",
+ ("GET", "/app/{path:path}"): "the application shell",
+ ("GET", "/v1/hub/info"): "read before sign-in: captcha key, instance policy",
+ ("GET", "/v1/hub/version"): "an installed client checks its minimum version first",
+ ("GET", "/v1/hub/pubkey"): "nodes cache the hub key on first contact",
+ ("GET", "/v1/health"): "supervision",
+ ("POST", "/v1/users/register"): "obtains an account — captcha, per-IP limit",
+ ("POST", "/v1/users/login"): "obtains a session — per-IP limit, per-name lockout",
+ ("POST", "/v1/users/auth"): "device sign-in — Ed25519 signature over a fresh timestamp",
+ ("POST", "/v1/users/token/refresh"): "the refresh token is the credential",
+ ("POST", "/v1/users/verify-email"): "the e-mailed code is the credential, attempts capped",
+ ("POST", "/v1/users/password/reset-request"): "captcha, per-IP and per-account limits",
+ ("POST", "/v1/users/password/reset"): "the e-mailed code is the credential, attempts capped",
+ ("POST", "/v1/nodes/auth"): "node sign-in — Ed25519 signature over a fresh timestamp",
+ ("WS", "/v1/nodes/ws"): "a node-scoped JWT in the first message, within a timeout",
+ ("GET", "/v1/groups"): "the public directory — empty when public groups are off",
+ ("GET", "/v1/blocklist"): "nodes sync it on their own behalf — hashes only",
+ ("GET", "/v1/blocklist/check"): "nodes consult it on their own behalf",
+ ("GET", "/v1/relays"): "closed: 503 while relay.RELAYS_ENABLED is False",
+ ("POST", "/v1/relays/register"): "closed; when open, approved key + signature",
+ ("GET", "/mhp/info"): "closed: 503 while federation.FEDERATION_ENABLED is False",
+ ("GET", "/mhp/directory"): "closed; when open, an MHP token",
+ ("POST", "/mhp/directory"): "closed; when open, an MHP token",
+ ("POST", "/mhp/revoke"): "closed; when open, an MHP token",
+}
+
+
+def _dependency_names(dependant, acc):
+ if dependant.call is not None:
+ acc.add(getattr(dependant.call, "__name__", ""))
+ for d in dependant.dependencies:
+ _dependency_names(d, acc)
+ return acc
+
+
+def _routes(routes):
+ for r in routes:
+ # FastAPI wraps an included router rather than copying its routes.
+ inner = getattr(r, "original_router", None)
+ if inner is not None:
+ yield from _routes(inner.routes)
+ elif isinstance(r, (APIRoute, APIWebSocketRoute)):
+ yield r
+
+
+@pytest.mark.asyncio
+async def test_every_route_without_an_account_is_one_somebody_chose(app):
+ found = set()
+ for route in _routes(app.routes):
+ if _dependency_names(route.dependant, set()) & AUTH_DEPENDENCIES:
+ continue
+ for method in (getattr(route, "methods", None) or {"WS"}):
+ found.add((method, route.path))
+
+ assert found - set(PUBLIC) == set(), (
+ "open route(s) nobody has reviewed — add an auth dependency, or list "
+ "them in PUBLIC with the reason")
+ assert set(PUBLIC) - found == set(), "PUBLIC lists routes that are gone or now authenticated"
+
+
+@pytest.mark.asyncio
+async def test_the_walk_sees_the_whole_api(app):
+ """The test above passes vacuously if the walk finds nothing."""
+ assert sum(1 for _ in _routes(app.routes)) > 80
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("path", ["/docs", "/redoc", "/openapi.json"])
+async def test_the_api_describes_itself_to_nobody(client, path):
+ r = await client.get(path)
+ assert r.status_code == 404
+ assert "swagger" not in r.text.lower() and "openapi" not in r.text.lower()
+
+
+class _SilentSocket:
+ """A client that connects and never says anything."""
+
+ def __init__(self):
+ self.sent, self.closed_with = [], None
+
+ async def accept(self):
+ pass
+
+ async def receive_text(self):
+ await asyncio.Event().wait()
+
+ async def send_text(self, text):
+ self.sent.append(json.loads(text))
+
+ async def close(self, code=1000):
+ self.closed_with = code
+
+
+@pytest.mark.asyncio
+async def test_a_socket_that_never_authenticates_is_closed(monkeypatch):
+ """The node socket is accepted before anyone is known; silence is not a lease.
+
+ Driven directly: the suite's transport has no WebSocket support.
+ """
+ from meshbay_hub.api import revocation
+
+ monkeypatch.setattr(revocation, "NODE_WS_AUTH_TIMEOUT", 0.05)
+ ws = _SilentSocket()
+ await asyncio.wait_for(revocation.node_websocket(ws), timeout=5)
+
+ assert ws.closed_with == 4001
+ assert ws.sent == [{"type": "error", "detail": "Authentication timed out"}]