fix: address PR review round 2 — expiry checks, race guards, poll tests
- Log warning if /app mount not found when sharing poll task group - Add docstring explaining unconditional task group creation - Check session expires_at in provision_status to catch stale sessions - Guard _poll_and_store status writes against cleanup-while-polling race - Use "error" status (not "expired") when app_password is missing - Remove hardcoded "Astrolabe Background Sync" user_agent string - Fix async mock pattern (new_callable=AsyncMock) in test - Add autouse fixture to clear _provision_sessions between tests - Add _poll_and_store unit tests: completed, expired, error, cleanup - Document all status values in provision_status docstring Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
777a09c806
commit
2508f36ebf
@@ -1413,16 +1413,28 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
|
||||
@asynccontextmanager
|
||||
async def _maybe_login_flow_cleanup(app: Starlette):
|
||||
"""Start Login Flow cleanup task and provision poll task group."""
|
||||
"""Start Login Flow cleanup task and provision poll task group.
|
||||
|
||||
The task group is always created (even when Login Flow cleanup is
|
||||
disabled) because provision routes use it to spawn background poll
|
||||
tasks via ``browser_app.state.poll_task_group``.
|
||||
"""
|
||||
async with anyio.create_task_group() as tg:
|
||||
if settings.enable_login_flow:
|
||||
tg.start_soon(_login_flow_cleanup_loop)
|
||||
# Share task group with provision routes for background polling
|
||||
found_app_mount = False
|
||||
for route in app.routes:
|
||||
if isinstance(route, Mount) and route.path == "/app":
|
||||
browser_app = cast(Starlette, route.app)
|
||||
browser_app.state.poll_task_group = tg
|
||||
found_app_mount = True
|
||||
break
|
||||
if not found_app_mount:
|
||||
logger.warning(
|
||||
"Could not find /app mount to share poll task group; "
|
||||
"web provisioning will return 500"
|
||||
)
|
||||
yield
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
|
||||
@@ -64,7 +64,8 @@ async def _poll_and_store(provision_id: str) -> None:
|
||||
settings = get_settings()
|
||||
nextcloud_host = settings.nextcloud_host
|
||||
if not nextcloud_host:
|
||||
session["status"] = "expired"
|
||||
if provision_id in _provision_sessions:
|
||||
session["status"] = "error"
|
||||
return
|
||||
|
||||
flow_client = LoginFlowV2Client(
|
||||
@@ -96,7 +97,11 @@ async def _poll_and_store(provision_id: str) -> None:
|
||||
storage = await get_shared_storage()
|
||||
effective_user_id = user_id or result.login_name or "unknown"
|
||||
if not result.app_password:
|
||||
session["status"] = "expired"
|
||||
# Re-fetch session to avoid writing to orphaned dict if
|
||||
# _cleanup_expired_sessions removed it while we were polling
|
||||
session = _provision_sessions.get(provision_id)
|
||||
if session:
|
||||
session["status"] = "error"
|
||||
logger.error(
|
||||
f"Login Flow v2 completed but no app_password (provision_id={provision_id})"
|
||||
)
|
||||
@@ -107,8 +112,10 @@ async def _poll_and_store(provision_id: str) -> None:
|
||||
scopes=None, # All scopes
|
||||
username=result.login_name,
|
||||
)
|
||||
session["status"] = "completed"
|
||||
session["username"] = result.login_name
|
||||
session = _provision_sessions.get(provision_id)
|
||||
if session:
|
||||
session["status"] = "completed"
|
||||
session["username"] = result.login_name
|
||||
logger.info(
|
||||
f"Login Flow v2 web provision completed for user {effective_user_id} "
|
||||
f"(provision_id={provision_id})"
|
||||
@@ -116,7 +123,9 @@ async def _poll_and_store(provision_id: str) -> None:
|
||||
return
|
||||
|
||||
if result.status == "expired":
|
||||
session["status"] = "expired"
|
||||
session = _provision_sessions.get(provision_id)
|
||||
if session:
|
||||
session["status"] = "expired"
|
||||
logger.warning(
|
||||
f"Login Flow v2 web provision expired (provision_id={provision_id})"
|
||||
)
|
||||
@@ -125,7 +134,9 @@ async def _poll_and_store(provision_id: str) -> None:
|
||||
await anyio.sleep(2)
|
||||
|
||||
# Timed out
|
||||
session["status"] = "expired"
|
||||
session = _provision_sessions.get(provision_id)
|
||||
if session:
|
||||
session["status"] = "expired"
|
||||
logger.warning(
|
||||
f"Login Flow v2 web provision timed out (provision_id={provision_id})"
|
||||
)
|
||||
@@ -177,9 +188,7 @@ async def provision_page(request: Request) -> RedirectResponse | HTMLResponse:
|
||||
nextcloud_host=nextcloud_host,
|
||||
verify_ssl=get_nextcloud_ssl_verify(),
|
||||
)
|
||||
init_response = await flow_client.initiate(
|
||||
user_agent="Astrolabe Background Sync"
|
||||
)
|
||||
init_response = await flow_client.initiate()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initiate Login Flow v2 for web provision: {e}")
|
||||
return HTMLResponse(
|
||||
@@ -234,7 +243,12 @@ async def provision_status(request: Request) -> JSONResponse:
|
||||
|
||||
GET /app/provision/status?id=...
|
||||
|
||||
Returns JSON: {"status": "pending"|"completed"|"expired", "username": "..."}
|
||||
Returns JSON with status field:
|
||||
- ``"pending"`` — flow in progress, poll again
|
||||
- ``"completed"`` — app password stored, includes ``"username"``
|
||||
- ``"expired"`` — flow timed out or was rejected by Nextcloud
|
||||
- ``"error"`` — flow completed but server-side error (e.g. missing app password)
|
||||
- ``"not_found"`` — unknown or already-consumed session (404)
|
||||
"""
|
||||
provision_id = request.query_params.get("id", "")
|
||||
|
||||
@@ -248,6 +262,12 @@ async def provision_status(request: Request) -> JSONResponse:
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Detect sessions that outlived their TTL (e.g. no new provision
|
||||
# requests triggered _cleanup_expired_sessions)
|
||||
if session["expires_at"] < time.time():
|
||||
_provision_sessions.pop(provision_id, None)
|
||||
return JSONResponse({"status": "expired"}, status_code=404)
|
||||
|
||||
response: dict = {"status": session["status"]}
|
||||
if session["status"] == "completed":
|
||||
response["username"] = session.get("username")
|
||||
|
||||
Reference in New Issue
Block a user