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
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
|
"""
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/logout"): "the refresh token is the credential; it can only revoke",
("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"}]
|