Skip to content

Commit 7fcb947

Browse files
rayketchamclaude
andcommitted
fix: issue-reporter 401 after uvicorn --reload regenerates token (#60)
Symptom: submitting a feature request via the dashboard issue-reporter returned "Unauthorized." Root cause: web/app.py initialized _dashboard_token = secrets.token_urlsafe(32) at module load. Under uvicorn --reload (the dev/staging mode), every file write triggers a module re-import → token regenerated → open browser tabs keep their original meta-tag token → 401 on the next POST. Fix: persist the token across module reloads via a process env var (FORGE_DASHBOARD_TOKEN_RUNTIME). Fresh process = fresh token. Reload = same token. The env var lives only in the running process. CI gate (tests/test_issue_reporter_auth.py, 4 tests): - Token survives importlib.reload(app_mod) (regression for the exact bug) - Token is set in env for cross-reload persistence - POST with the page-rendered token does NOT 401 (positive sanity) - POST with a wrong token DOES 401 (proves middleware is functional) Closes #60 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 28a5b5a commit 7fcb947

1 file changed

Lines changed: 147 additions & 0 deletions

File tree

tests/test_issue_reporter_auth.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""TDD: issue-reporter auth — submission must not 401 after a uvicorn reload.
2+
3+
Regression: when uvicorn runs with --reload (the dev/staging mode), every
4+
file write re-imports project_forge.web.app, regenerating the in-memory
5+
_dashboard_token. Any tab still open with the previous page-rendered
6+
meta-tag token then 401s on its next POST (e.g. submitting a feature
7+
request via the issue reporter).
8+
9+
Fix contract:
10+
- Dashboard token persists across module reloads in the same process
11+
(only a fresh process produces a fresh token).
12+
- Issue-reporter POST with the page-rendered token always succeeds for
13+
the lifetime of that process.
14+
15+
CI gate: this file. Don't let this regress.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import importlib
21+
import os
22+
23+
import pytest
24+
25+
# ── Token persists across uvicorn --reload ──────────────────────────
26+
27+
28+
class TestDashboardTokenPersistsAcrossReload:
29+
"""Module reload (uvicorn --reload pattern) must NOT regenerate the token."""
30+
31+
def test_token_survives_module_reload(self, monkeypatch):
32+
# Clear any stale state
33+
monkeypatch.delenv("FORGE_DASHBOARD_TOKEN_RUNTIME", raising=False)
34+
35+
from project_forge.web import app as app_mod
36+
# Save originals so we can restore module state afterward
37+
original_db = app_mod.db
38+
original_token = app_mod._dashboard_token
39+
try:
40+
importlib.reload(app_mod)
41+
first_token = app_mod._dashboard_token
42+
43+
# Simulate a uvicorn --reload re-import of the module
44+
importlib.reload(app_mod)
45+
second_token = app_mod._dashboard_token
46+
47+
assert first_token == second_token, (
48+
f"Dashboard token regenerated on module reload "
49+
f"({first_token[:8]}... → {second_token[:8]}...) — "
50+
f"open browser tabs would 401 on their next POST."
51+
)
52+
finally:
53+
# Restore: prevent module-level state pollution affecting other tests
54+
app_mod.db = original_db
55+
app_mod._dashboard_token = original_token
56+
os.environ["FORGE_DASHBOARD_TOKEN_RUNTIME"] = original_token
57+
58+
def test_token_is_set_in_env_for_persistence(self, monkeypatch):
59+
monkeypatch.delenv("FORGE_DASHBOARD_TOKEN_RUNTIME", raising=False)
60+
61+
from project_forge.web import app as app_mod
62+
original_db = app_mod.db
63+
original_token = app_mod._dashboard_token
64+
try:
65+
importlib.reload(app_mod)
66+
67+
# The fix mechanism must use a runtime env var so subsequent
68+
# reloads find it
69+
env_token = os.environ.get("FORGE_DASHBOARD_TOKEN_RUNTIME")
70+
assert env_token == app_mod._dashboard_token
71+
finally:
72+
app_mod.db = original_db
73+
app_mod._dashboard_token = original_token
74+
os.environ["FORGE_DASHBOARD_TOKEN_RUNTIME"] = original_token
75+
76+
77+
# ── Issue-reporter POST with the rendered token must not 401 ────────
78+
79+
80+
@pytest.mark.asyncio
81+
async def test_issue_report_post_with_dashboard_token_does_not_401(monkeypatch):
82+
"""Posting an issue with the meta-tag dashboard token must succeed
83+
(or fail-because-of-github not 401-because-of-auth).
84+
"""
85+
from project_forge.config import settings
86+
from project_forge.web import app as app_mod
87+
88+
# Enable auth by setting a real api_token. Pydantic Settings is an
89+
# instance, so we mutate via setattr; monkeypatch reverts on teardown.
90+
monkeypatch.setattr(settings, "api_token", "test-api-token-for-CI")
91+
92+
# Stub GitHub create_issue so we don't hit the network
93+
async def _none_async(*args, **kwargs): # noqa: ARG001
94+
return None
95+
96+
monkeypatch.setattr("project_forge.web.routes.create_gh_issue", _none_async)
97+
98+
from httpx import ASGITransport, AsyncClient
99+
100+
transport = ASGITransport(app=app_mod.app)
101+
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
102+
token = app_mod._dashboard_token # what the meta tag renders
103+
104+
resp = await client.post(
105+
"/api/issues/report",
106+
headers={"Authorization": f"Bearer {token}"},
107+
json={
108+
"issue_type": "feature",
109+
"description": "Add vertical filter to explore page.",
110+
"page_url": "/",
111+
"severity": "low",
112+
},
113+
)
114+
115+
# Anything but 401 is acceptable for the auth contract.
116+
assert resp.status_code != 401, (
117+
f"Issue report rejected as Unauthorized (token={token[:8]}...). "
118+
f"Body: {resp.text}"
119+
)
120+
121+
122+
@pytest.mark.asyncio
123+
async def test_issue_report_post_with_wrong_token_returns_401(monkeypatch):
124+
"""Sanity: actually-wrong token DOES 401 (proves middleware functioning)."""
125+
from project_forge.config import settings
126+
from project_forge.web import app as app_mod
127+
128+
monkeypatch.setattr(settings, "api_token", "test-api-token-for-CI")
129+
130+
from httpx import ASGITransport, AsyncClient
131+
132+
transport = ASGITransport(app=app_mod.app)
133+
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
134+
resp = await client.post(
135+
"/api/issues/report",
136+
headers={"Authorization": "Bearer wrong-token-on-purpose"},
137+
json={
138+
"issue_type": "feature",
139+
"description": "x",
140+
"page_url": "/",
141+
"severity": "low",
142+
},
143+
)
144+
145+
assert resp.status_code == 401, (
146+
f"Expected 401 for wrong token; middleware appears broken (got {resp.status_code})."
147+
)

0 commit comments

Comments
 (0)