forked from ChelseaKR/queer-the-stacks
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_auth.py
More file actions
294 lines (212 loc) · 10.6 KB
/
Copy pathtest_auth.py
File metadata and controls
294 lines (212 loc) · 10.6 KB
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
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
"""Security guardrail — the dashboard is reachable only behind auth (merge-blocking)."""
from __future__ import annotations
import time
from pathlib import Path
import pytest
from app.auth import (
SESSION_TTL_SECONDS,
AuthNotConfigured,
LoginLockoutTracker,
check_credentials,
expected_token,
sign_session,
verify_session,
)
def test_demo_mode_uses_demo_token() -> None:
env = {"STACKS_DEMO": "1"}
assert expected_token(env) == "demo-token"
assert check_credentials("demo-token", env) is True
assert check_credentials("wrong", env) is False
assert check_credentials(None, env) is False
def test_real_mode_requires_env_token() -> None:
with pytest.raises(AuthNotConfigured):
expected_token({}) # no demo, no token -> fail closed
def test_real_mode_token_from_env() -> None:
env = {"STACKS_AUTH_TOKEN": "s3cr3t-token-value"}
assert check_credentials("s3cr3t-token-value", env) is True
assert check_credentials("nope", env) is False
def test_server_rejects_unauthenticated_requests(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The FastAPI app returns 401 with no/invalid token and 200 with a valid one."""
fastapi = pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
from tests.conftest import seed_store_from_env
# Demo mode + a throwaway data dir so the server never touches the repo's data/.
monkeypatch.setenv("STACKS_DEMO", "1")
monkeypatch.setenv("STACKS_DATA_DIR", str(tmp_path))
from app.server import create_app
# The server never ingests inside a request (FIX-14) — populate the store
# explicitly first, the same way `stacks refresh` would.
seed_store_from_env()
client = TestClient(create_app())
# Health is open; the dashboard is not.
assert client.get("/healthz").status_code == 200
assert client.get("/").status_code == 401
assert client.get("/", headers={"Authorization": "Bearer wrong"}).status_code == 401
ok = client.get("/", headers={"Authorization": "Bearer demo-token"})
assert ok.status_code == 200
assert "Queer the Stacks" in ok.text
assert fastapi # used
# --- app/auth.py: signed session cookies -----------------------------------
def test_sign_and_verify_session_round_trip() -> None:
env = {"STACKS_AUTH_TOKEN": "s3cr3t-token-value"}
now = 1_700_000_000
cookie = sign_session(now, env)
assert "." in cookie
assert verify_session(cookie, now, env) is True
assert verify_session(cookie, now + 60, env) is True # comfortably within TTL
def test_verify_session_rejects_tampered_cookie() -> None:
env = {"STACKS_AUTH_TOKEN": "s3cr3t-token-value"}
now = 1_700_000_000
cookie = sign_session(now, env)
issued_at_b64, _, sig = cookie.partition(".")
tampered_sig = "0" * len(sig) if sig[0] != "0" else "1" * len(sig)
assert verify_session(f"{issued_at_b64}.{tampered_sig}", now, env) is False
# a cookie signed under a different token must not verify under this one
other = sign_session(now, {"STACKS_AUTH_TOKEN": "different-token-value"})
assert verify_session(other, now, env) is False
assert verify_session(None, now, env) is False
assert verify_session("no-dot-at-all", now, env) is False
assert verify_session("", now, env) is False
assert verify_session(".", now, env) is False # empty issued-at and empty sig
def test_verify_session_rejects_correctly_signed_but_undecodable_payload() -> None:
"""A validly-signed cookie whose payload isn't a base64-encoded integer must
still fail closed (defends against a signing-key/payload-format mismatch)."""
import hashlib
import hmac as hmac_module
from app.auth import _signing_key # noqa: PLC0415 - internal, test-only reach-in
env = {"STACKS_AUTH_TOKEN": "s3cr3t-token-value"}
bogus_payload = "not-a-valid-b64-int"
sig = hmac_module.new(
_signing_key(env), bogus_payload.encode("ascii"), hashlib.sha256
).hexdigest()
assert verify_session(f"{bogus_payload}.{sig}", 1_700_000_000, env) is False
def test_verify_session_fails_closed_when_auth_not_configured() -> None:
# No STACKS_AUTH_TOKEN and no demo mode -> expected_token() raises -> reject.
assert verify_session("YW55.deadbeef", 1_700_000_000, {}) is False
def test_verify_session_rejects_expired_cookie() -> None:
env = {"STACKS_AUTH_TOKEN": "s3cr3t-token-value"}
now = 1_700_000_000
cookie = sign_session(now, env)
assert verify_session(cookie, now + SESSION_TTL_SECONDS + 1, env) is False
assert verify_session(cookie, now + SESSION_TTL_SECONDS, env) is True # exactly at TTL edge
assert verify_session(cookie, now - 1, env) is False # backdated relative to "now" -> reject
# --- app/auth.py: failed-login lockout tracker ------------------------------
def test_lockout_tracker_locks_after_n_failures_and_expires() -> None:
tracker = LoginLockoutTracker(max_failures=3, window_seconds=900)
now = 1_700_000_000
assert tracker.is_locked_out("1.2.3.4", now) is False
tracker.record_failure("1.2.3.4", now)
tracker.record_failure("1.2.3.4", now)
assert tracker.is_locked_out("1.2.3.4", now) is False # 2 failures, not yet locked
tracker.record_failure("1.2.3.4", now)
assert tracker.is_locked_out("1.2.3.4", now) is True # 3rd failure trips it
assert tracker.is_locked_out("5.6.7.8", now) is False # a different IP is unaffected
assert tracker.is_locked_out("1.2.3.4", now + 901) is False # window has elapsed
def test_lockout_tracker_reset_clears_history() -> None:
tracker = LoginLockoutTracker(max_failures=3, window_seconds=900)
now = 1_700_000_000
for _ in range(3):
tracker.record_failure("9.9.9.9", now)
assert tracker.is_locked_out("9.9.9.9", now) is True
tracker.reset("9.9.9.9")
assert tracker.is_locked_out("9.9.9.9", now) is False
# --- app/server.py: browser session flow, via FastAPI TestClient -----------
@pytest.fixture(autouse=True)
def _reset_server_lockout() -> None:
"""The server's lockout tracker is process-local (module-level singleton) so
it survives real restarts too — but that means every TestClient in this file
shares it (they all present the same synthetic ``testclient`` IP). Reset it
between tests so failures recorded by one test can't lock out another."""
pytest.importorskip("fastapi")
from app.server import _lockout
_lockout._failures.clear()
yield
_lockout._failures.clear()
def _demo_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): # type: ignore[no-untyped-def]
from fastapi.testclient import TestClient
monkeypatch.setenv("STACKS_DEMO", "1")
monkeypatch.setenv("STACKS_DATA_DIR", str(tmp_path))
from tests.conftest import seed_store_from_env
seed_store_from_env()
from app.server import create_app
# https:// base_url: the session cookie is Secure, so an http:// test client
# would silently withhold it on every subsequent request (correctly mirroring
# real browser behaviour) and every cookie-carrying assertion below would fail.
return TestClient(create_app(), base_url="https://testserver")
def test_server_tampered_cookie_rejected(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
pytest.importorskip("fastapi")
client = _demo_client(tmp_path, monkeypatch)
client.cookies.set("stacks_session", "not-a-real-payload.deadbeefdeadbeef")
assert client.get("/").status_code == 401
def test_server_expired_cookie_rejected(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
pytest.importorskip("fastapi")
client = _demo_client(tmp_path, monkeypatch)
stale = sign_session(int(time.time()) - SESSION_TTL_SECONDS - 10, {"STACKS_DEMO": "1"})
client.cookies.set("stacks_session", stale)
assert client.get("/").status_code == 401
def test_server_valid_cookie_reaches_dashboard(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
pytest.importorskip("fastapi")
client = _demo_client(tmp_path, monkeypatch)
fresh = sign_session(int(time.time()), {"STACKS_DEMO": "1"})
client.cookies.set("stacks_session", fresh)
ok = client.get("/")
assert ok.status_code == 200
assert "Queer the Stacks" in ok.text
def test_login_success_sets_cookie_and_grants_access(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
pytest.importorskip("fastapi")
client = _demo_client(tmp_path, monkeypatch)
form = client.get("/login")
assert form.status_code == 200
assert '<label for="token">' in form.text
assert '<input id="token" name="token"' in form.text
resp = client.post("/login", data={"token": "demo-token"}, follow_redirects=False)
assert resp.status_code == 303
assert resp.headers["location"] == "/"
set_cookie = resp.headers.get("set-cookie", "")
assert "stacks_session=" in set_cookie
assert "httponly" in set_cookie.lower()
assert "secure" in set_cookie.lower()
assert "samesite=strict" in set_cookie.lower()
# the cookie now stored on the client reaches the dashboard with no header
ok = client.get("/")
assert ok.status_code == 200
assert "Queer the Stacks" in ok.text
def test_login_failure_does_not_grant_access(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
pytest.importorskip("fastapi")
client = _demo_client(tmp_path, monkeypatch)
resp = client.post("/login", data={"token": "wrong"})
assert resp.status_code == 401
assert "set-cookie" not in {k.lower() for k in resp.headers}
assert client.get("/").status_code == 401
def test_logout_clears_cookie(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
pytest.importorskip("fastapi")
client = _demo_client(tmp_path, monkeypatch)
client.post("/login", data={"token": "demo-token"})
assert client.get("/").status_code == 200
resp = client.get("/logout", follow_redirects=False)
assert resp.status_code == 303
assert resp.headers["location"] == "/login"
assert client.get("/").status_code == 401
def test_lockout_after_n_failed_logins_returns_429(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
pytest.importorskip("fastapi")
from app.auth import LOCKOUT_MAX_FAILURES
client = _demo_client(tmp_path, monkeypatch)
for _ in range(LOCKOUT_MAX_FAILURES):
resp = client.post("/login", data={"token": "wrong"})
assert resp.status_code == 401
locked = client.post("/login", data={"token": "wrong"})
assert locked.status_code == 429
# even the correct token is refused while locked out
still_locked = client.post("/login", data={"token": "demo-token"})
assert still_locked.status_code == 429
assert client.get("/").status_code == 401