diff options
Diffstat (limited to 'packages/meshbay-hub/tests/test_unauthenticated_surface.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_unauthenticated_surface.py | 134 |
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"}] |