fix: address PR review feedback for HF user telemetry
Browse files- Replace silent `except Exception: pass` with proper logging and
narrowed catch (binascii.Error, json.JSONDecodeError, etc.)
- Add IS_HF_SPACES to hardware.py as source of truth for HF Spaces
detection, use consistently in analysis.py and ui/app.py
- Update privacy docs to reflect intentional raw username storage
- Add tracker-level, event dataclass, and edge case tests for new
is_logged_in/hf_username fields
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- src/mosaic/analysis.py +2 -1
- src/mosaic/hardware.py +4 -0
- src/mosaic/telemetry/__init__.py +1 -1
- src/mosaic/telemetry/tracker.py +3 -3
- src/mosaic/telemetry/utils.py +8 -5
- src/mosaic/ui/app.py +2 -2
- tests/telemetry/test_events.py +59 -0
- tests/telemetry/test_tracker.py +50 -0
- tests/telemetry/test_utils.py +33 -0
src/mosaic/analysis.py
CHANGED
|
@@ -26,6 +26,7 @@ from mosaic.telemetry import extract_user_info
|
|
| 26 |
# Import centralized hardware detection
|
| 27 |
from mosaic.hardware import (
|
| 28 |
spaces,
|
|
|
|
| 29 |
IS_ZEROGPU,
|
| 30 |
IS_T4_GPU,
|
| 31 |
GPU_TYPE,
|
|
@@ -808,7 +809,7 @@ def analyze_slide(
|
|
| 808 |
raise gr.Error("Please upload a slide.")
|
| 809 |
|
| 810 |
# Extract user info for telemetry (HF Spaces only)
|
| 811 |
-
user_info = extract_user_info(request,
|
| 812 |
|
| 813 |
# Initialize telemetry for resource tracking
|
| 814 |
slide_start_time = time_module.time()
|
|
|
|
| 26 |
# Import centralized hardware detection
|
| 27 |
from mosaic.hardware import (
|
| 28 |
spaces,
|
| 29 |
+
IS_HF_SPACES,
|
| 30 |
IS_ZEROGPU,
|
| 31 |
IS_T4_GPU,
|
| 32 |
GPU_TYPE,
|
|
|
|
| 809 |
raise gr.Error("Please upload a slide.")
|
| 810 |
|
| 811 |
# Extract user info for telemetry (HF Spaces only)
|
| 812 |
+
user_info = extract_user_info(request, IS_HF_SPACES)
|
| 813 |
|
| 814 |
# Initialize telemetry for resource tracking
|
| 815 |
slide_start_time = time_module.time()
|
src/mosaic/hardware.py
CHANGED
|
@@ -8,6 +8,9 @@ import os
|
|
| 8 |
import torch
|
| 9 |
from loguru import logger
|
| 10 |
|
|
|
|
|
|
|
|
|
|
| 11 |
# Detect HuggingFace Spaces ZeroGPU environment
|
| 12 |
try:
|
| 13 |
import spaces
|
|
@@ -95,6 +98,7 @@ def get_gpu_metrics() -> dict:
|
|
| 95 |
__all__ = [
|
| 96 |
"spaces",
|
| 97 |
"HAS_SPACES",
|
|
|
|
| 98 |
"IS_ZEROGPU",
|
| 99 |
"IS_T4_GPU",
|
| 100 |
"GPU_NAME",
|
|
|
|
| 8 |
import torch
|
| 9 |
from loguru import logger
|
| 10 |
|
| 11 |
+
# Detect HuggingFace Spaces environment
|
| 12 |
+
IS_HF_SPACES = bool(os.environ.get("SPACE_ID"))
|
| 13 |
+
|
| 14 |
# Detect HuggingFace Spaces ZeroGPU environment
|
| 15 |
try:
|
| 16 |
import spaces
|
|
|
|
| 98 |
__all__ = [
|
| 99 |
"spaces",
|
| 100 |
"HAS_SPACES",
|
| 101 |
+
"IS_HF_SPACES",
|
| 102 |
"IS_ZEROGPU",
|
| 103 |
"IS_T4_GPU",
|
| 104 |
"GPU_NAME",
|
src/mosaic/telemetry/__init__.py
CHANGED
|
@@ -8,7 +8,7 @@ This module provides lightweight telemetry for the Mosaic HuggingFace app to tra
|
|
| 8 |
Key features:
|
| 9 |
- Gradio-only: No telemetry for CLI batch processing
|
| 10 |
- No external dependencies: Uses stdlib only (json, dataclasses)
|
| 11 |
-
- Privacy-first: Session IDs hashed, no file paths
|
| 12 |
- File-based storage: JSONL format with daily rotation
|
| 13 |
- HF Spaces compatible: Uses /data persistent storage when available
|
| 14 |
|
|
|
|
| 8 |
Key features:
|
| 9 |
- Gradio-only: No telemetry for CLI batch processing
|
| 10 |
- No external dependencies: Uses stdlib only (json, dataclasses)
|
| 11 |
+
- Privacy-first: Session IDs hashed, no file paths stored. HF usernames recorded for usage tracking.
|
| 12 |
- File-based storage: JSONL format with daily rotation
|
| 13 |
- HF Spaces compatible: Uses /data persistent storage when available
|
| 14 |
|
src/mosaic/telemetry/tracker.py
CHANGED
|
@@ -232,7 +232,7 @@ class TelemetryTracker:
|
|
| 232 |
duration_sec: Analysis duration (for analysis_complete only)
|
| 233 |
success: Whether analysis succeeded (for analysis_complete only)
|
| 234 |
is_logged_in: True if HF user logged in
|
| 235 |
-
hf_username:
|
| 236 |
"""
|
| 237 |
if not self._is_enabled():
|
| 238 |
return
|
|
@@ -298,7 +298,7 @@ class TelemetryTracker:
|
|
| 298 |
gpu_type: GPU type string
|
| 299 |
peak_gpu_memory_gb: Peak GPU memory usage in GB
|
| 300 |
is_logged_in: True if HF user logged in
|
| 301 |
-
hf_username:
|
| 302 |
"""
|
| 303 |
if not self._is_enabled():
|
| 304 |
return
|
|
@@ -349,7 +349,7 @@ class TelemetryTracker:
|
|
| 349 |
slide_count: Number of slides if known
|
| 350 |
gpu_type: GPU type string
|
| 351 |
is_logged_in: True if HF user logged in
|
| 352 |
-
hf_username:
|
| 353 |
"""
|
| 354 |
if not self._is_enabled():
|
| 355 |
return
|
|
|
|
| 232 |
duration_sec: Analysis duration (for analysis_complete only)
|
| 233 |
success: Whether analysis succeeded (for analysis_complete only)
|
| 234 |
is_logged_in: True if HF user logged in
|
| 235 |
+
hf_username: HF username (HF Spaces only)
|
| 236 |
"""
|
| 237 |
if not self._is_enabled():
|
| 238 |
return
|
|
|
|
| 298 |
gpu_type: GPU type string
|
| 299 |
peak_gpu_memory_gb: Peak GPU memory usage in GB
|
| 300 |
is_logged_in: True if HF user logged in
|
| 301 |
+
hf_username: HF username (HF Spaces only)
|
| 302 |
"""
|
| 303 |
if not self._is_enabled():
|
| 304 |
return
|
|
|
|
| 349 |
slide_count: Number of slides if known
|
| 350 |
gpu_type: GPU type string
|
| 351 |
is_logged_in: True if HF user logged in
|
| 352 |
+
hf_username: HF username (HF Spaces only)
|
| 353 |
"""
|
| 354 |
if not self._is_enabled():
|
| 355 |
return
|
src/mosaic/telemetry/utils.py
CHANGED
|
@@ -9,6 +9,7 @@ This module provides helper utilities:
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
import base64
|
|
|
|
| 12 |
import hashlib
|
| 13 |
import json
|
| 14 |
import re
|
|
@@ -17,6 +18,8 @@ from contextlib import contextmanager
|
|
| 17 |
from dataclasses import dataclass
|
| 18 |
from typing import Dict, Optional
|
| 19 |
|
|
|
|
|
|
|
| 20 |
|
| 21 |
@contextmanager
|
| 22 |
def StageTimer(stage_name: str, timings: Dict[str, float]):
|
|
@@ -130,7 +133,7 @@ def extract_user_info(request, is_hf_spaces: bool = False) -> UserInfo:
|
|
| 130 |
UserInfo with is_logged_in and username (or defaults if extraction fails)
|
| 131 |
|
| 132 |
Example:
|
| 133 |
-
user_info = extract_user_info(request,
|
| 134 |
if user_info.is_logged_in:
|
| 135 |
print(f"User: {user_info.username}")
|
| 136 |
"""
|
|
@@ -171,9 +174,9 @@ def extract_user_info(request, is_hf_spaces: bool = False) -> UserInfo:
|
|
| 171 |
username = token_data["onBehalfOf"]["user"]
|
| 172 |
return UserInfo(is_logged_in=True, username=username)
|
| 173 |
|
| 174 |
-
except
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
|
| 179 |
return UserInfo()
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
import base64
|
| 12 |
+
import binascii
|
| 13 |
import hashlib
|
| 14 |
import json
|
| 15 |
import re
|
|
|
|
| 18 |
from dataclasses import dataclass
|
| 19 |
from typing import Dict, Optional
|
| 20 |
|
| 21 |
+
from loguru import logger
|
| 22 |
+
|
| 23 |
|
| 24 |
@contextmanager
|
| 25 |
def StageTimer(stage_name: str, timings: Dict[str, float]):
|
|
|
|
| 133 |
UserInfo with is_logged_in and username (or defaults if extraction fails)
|
| 134 |
|
| 135 |
Example:
|
| 136 |
+
user_info = extract_user_info(request, IS_HF_SPACES)
|
| 137 |
if user_info.is_logged_in:
|
| 138 |
print(f"User: {user_info.username}")
|
| 139 |
"""
|
|
|
|
| 174 |
username = token_data["onBehalfOf"]["user"]
|
| 175 |
return UserInfo(is_logged_in=True, username=username)
|
| 176 |
|
| 177 |
+
except (json.JSONDecodeError, binascii.Error, UnicodeDecodeError, ValueError) as e:
|
| 178 |
+
logger.warning(f"Failed to decode JWT token from request: {e}")
|
| 179 |
+
except Exception as e:
|
| 180 |
+
logger.error(f"Unexpected error extracting user info: {e}")
|
| 181 |
|
| 182 |
return UserInfo()
|
src/mosaic/ui/app.py
CHANGED
|
@@ -33,7 +33,7 @@ from mosaic.ui.utils import (
|
|
| 33 |
)
|
| 34 |
from mosaic.analysis import analyze_slide
|
| 35 |
from mosaic.model_manager import load_all_models
|
| 36 |
-
from mosaic.hardware import DEFAULT_CONCURRENCY_LIMIT, IS_T4_GPU, GPU_TYPE
|
| 37 |
from mosaic.telemetry import extract_user_info
|
| 38 |
from mosaic.tcga import (
|
| 39 |
fetch_slide,
|
|
@@ -218,7 +218,7 @@ def analyze_slides(
|
|
| 218 |
session_hash = request.session_hash if request else None
|
| 219 |
|
| 220 |
# Extract user info for telemetry (HF Spaces only)
|
| 221 |
-
user_info = extract_user_info(request,
|
| 222 |
|
| 223 |
# Wait for core models download to complete (Paladin models can continue in background)
|
| 224 |
if _model_download_thread is not None and not _core_models_complete:
|
|
|
|
| 33 |
)
|
| 34 |
from mosaic.analysis import analyze_slide
|
| 35 |
from mosaic.model_manager import load_all_models
|
| 36 |
+
from mosaic.hardware import DEFAULT_CONCURRENCY_LIMIT, IS_HF_SPACES, IS_T4_GPU, GPU_TYPE
|
| 37 |
from mosaic.telemetry import extract_user_info
|
| 38 |
from mosaic.tcga import (
|
| 39 |
fetch_slide,
|
|
|
|
| 218 |
session_hash = request.session_hash if request else None
|
| 219 |
|
| 220 |
# Extract user info for telemetry (HF Spaces only)
|
| 221 |
+
user_info = extract_user_info(request, IS_HF_SPACES)
|
| 222 |
|
| 223 |
# Wait for core models download to complete (Paladin models can continue in background)
|
| 224 |
if _model_download_thread is not None and not _core_models_complete:
|
tests/telemetry/test_events.py
CHANGED
|
@@ -108,6 +108,37 @@ class TestUsageEvent:
|
|
| 108 |
assert data["slide_count"] == 3
|
| 109 |
assert data["session_hash"] is None
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
class TestResourceEvent:
|
| 113 |
"""Tests for ResourceEvent."""
|
|
@@ -147,6 +178,20 @@ class TestResourceEvent:
|
|
| 147 |
assert data["total_duration_sec"] == 100.0
|
| 148 |
assert data["tile_count"] == 500
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
class TestFailureEvent:
|
| 152 |
"""Tests for FailureEvent."""
|
|
@@ -190,6 +235,20 @@ class TestFailureEvent:
|
|
| 190 |
assert data["error_type"] == "MemoryError"
|
| 191 |
assert data["error_stage"] == "ctranspath"
|
| 192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
|
| 194 |
class TestEventIdGeneration:
|
| 195 |
"""Tests for event ID generation."""
|
|
|
|
| 108 |
assert data["slide_count"] == 3
|
| 109 |
assert data["session_hash"] is None
|
| 110 |
|
| 111 |
+
def test_user_info_fields(self):
|
| 112 |
+
"""Test is_logged_in and hf_username fields in UsageEvent."""
|
| 113 |
+
event = UsageEvent(
|
| 114 |
+
event_type="analysis_start",
|
| 115 |
+
analysis_id="test-123",
|
| 116 |
+
session_hash=None,
|
| 117 |
+
slide_count=1,
|
| 118 |
+
is_logged_in=True,
|
| 119 |
+
hf_username="testuser",
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
assert event.is_logged_in is True
|
| 123 |
+
assert event.hf_username == "testuser"
|
| 124 |
+
|
| 125 |
+
data = event.to_dict()
|
| 126 |
+
assert data["is_logged_in"] is True
|
| 127 |
+
assert data["hf_username"] == "testuser"
|
| 128 |
+
|
| 129 |
+
def test_user_info_fields_default_none(self):
|
| 130 |
+
"""Test that user info fields default to None in to_dict()."""
|
| 131 |
+
event = UsageEvent(
|
| 132 |
+
event_type="analysis_start",
|
| 133 |
+
analysis_id="test-123",
|
| 134 |
+
session_hash=None,
|
| 135 |
+
slide_count=1,
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
data = event.to_dict()
|
| 139 |
+
assert data["is_logged_in"] is None
|
| 140 |
+
assert data["hf_username"] is None
|
| 141 |
+
|
| 142 |
|
| 143 |
class TestResourceEvent:
|
| 144 |
"""Tests for ResourceEvent."""
|
|
|
|
| 178 |
assert data["total_duration_sec"] == 100.0
|
| 179 |
assert data["tile_count"] == 500
|
| 180 |
|
| 181 |
+
def test_user_info_fields(self):
|
| 182 |
+
"""Test is_logged_in and hf_username fields in ResourceEvent."""
|
| 183 |
+
event = ResourceEvent(
|
| 184 |
+
analysis_id="test-123",
|
| 185 |
+
session_hash=None,
|
| 186 |
+
total_duration_sec=100.0,
|
| 187 |
+
is_logged_in=True,
|
| 188 |
+
hf_username="testuser",
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
data = event.to_dict()
|
| 192 |
+
assert data["is_logged_in"] is True
|
| 193 |
+
assert data["hf_username"] == "testuser"
|
| 194 |
+
|
| 195 |
|
| 196 |
class TestFailureEvent:
|
| 197 |
"""Tests for FailureEvent."""
|
|
|
|
| 235 |
assert data["error_type"] == "MemoryError"
|
| 236 |
assert data["error_stage"] == "ctranspath"
|
| 237 |
|
| 238 |
+
def test_user_info_fields(self):
|
| 239 |
+
"""Test is_logged_in and hf_username fields in FailureEvent."""
|
| 240 |
+
event = FailureEvent(
|
| 241 |
+
error_type="ValueError",
|
| 242 |
+
error_message="test error",
|
| 243 |
+
error_stage="upload",
|
| 244 |
+
is_logged_in=False,
|
| 245 |
+
hf_username=None,
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
data = event.to_dict()
|
| 249 |
+
assert data["is_logged_in"] is False
|
| 250 |
+
assert data["hf_username"] is None
|
| 251 |
+
|
| 252 |
|
| 253 |
class TestEventIdGeneration:
|
| 254 |
"""Tests for event ID generation."""
|
tests/telemetry/test_tracker.py
CHANGED
|
@@ -137,6 +137,23 @@ class TestUsageEvents:
|
|
| 137 |
assert event["session_hash"] is not None
|
| 138 |
assert event["session_hash"] != "abc123"
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
def test_log_analysis_complete(self, tracker, temp_dir):
|
| 141 |
"""Test logging analysis complete event."""
|
| 142 |
tracker.log_app_start()
|
|
@@ -206,6 +223,22 @@ class TestResourceEvents:
|
|
| 206 |
assert event["tile_count"] == 1000
|
| 207 |
assert event["peak_gpu_memory_gb"] == 12.5
|
| 208 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
|
| 210 |
class TestFailureEvents:
|
| 211 |
"""Tests for failure event logging."""
|
|
@@ -231,6 +264,23 @@ class TestFailureEvents:
|
|
| 231 |
assert event["error_stage"] == "upload"
|
| 232 |
assert event["analysis_id"] == "test-123"
|
| 233 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
def test_error_message_sanitized(self, tracker, temp_dir):
|
| 235 |
"""Test that error messages are sanitized."""
|
| 236 |
tracker.log_failure_event(
|
|
|
|
| 137 |
assert event["session_hash"] is not None
|
| 138 |
assert event["session_hash"] != "abc123"
|
| 139 |
|
| 140 |
+
def test_log_usage_event_with_user_info(self, tracker, temp_dir):
|
| 141 |
+
"""Test that is_logged_in and hf_username are persisted in usage events."""
|
| 142 |
+
tracker.log_usage_event(
|
| 143 |
+
event_type="analysis_start",
|
| 144 |
+
analysis_id="test-user-info",
|
| 145 |
+
slide_count=1,
|
| 146 |
+
is_logged_in=True,
|
| 147 |
+
hf_username="testuser",
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
usage_files = list((temp_dir / "daily").glob("usage_*.jsonl"))
|
| 151 |
+
with open(usage_files[0]) as f:
|
| 152 |
+
event = json.loads(f.read().strip())
|
| 153 |
+
|
| 154 |
+
assert event["is_logged_in"] is True
|
| 155 |
+
assert event["hf_username"] == "testuser"
|
| 156 |
+
|
| 157 |
def test_log_analysis_complete(self, tracker, temp_dir):
|
| 158 |
"""Test logging analysis complete event."""
|
| 159 |
tracker.log_app_start()
|
|
|
|
| 223 |
assert event["tile_count"] == 1000
|
| 224 |
assert event["peak_gpu_memory_gb"] == 12.5
|
| 225 |
|
| 226 |
+
def test_log_resource_event_with_user_info(self, tracker, temp_dir):
|
| 227 |
+
"""Test that is_logged_in and hf_username are persisted in resource events."""
|
| 228 |
+
tracker.log_resource_event(
|
| 229 |
+
analysis_id="test-user-info",
|
| 230 |
+
total_duration_sec=60.0,
|
| 231 |
+
is_logged_in=True,
|
| 232 |
+
hf_username="testuser",
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
resource_files = list((temp_dir / "daily").glob("resource_*.jsonl"))
|
| 236 |
+
with open(resource_files[0]) as f:
|
| 237 |
+
event = json.loads(f.read().strip())
|
| 238 |
+
|
| 239 |
+
assert event["is_logged_in"] is True
|
| 240 |
+
assert event["hf_username"] == "testuser"
|
| 241 |
+
|
| 242 |
|
| 243 |
class TestFailureEvents:
|
| 244 |
"""Tests for failure event logging."""
|
|
|
|
| 264 |
assert event["error_stage"] == "upload"
|
| 265 |
assert event["analysis_id"] == "test-123"
|
| 266 |
|
| 267 |
+
def test_log_failure_event_with_user_info(self, tracker, temp_dir):
|
| 268 |
+
"""Test that is_logged_in and hf_username are persisted in failure events."""
|
| 269 |
+
tracker.log_failure_event(
|
| 270 |
+
error_type="ValueError",
|
| 271 |
+
error_message="test error",
|
| 272 |
+
error_stage="upload",
|
| 273 |
+
is_logged_in=False,
|
| 274 |
+
hf_username=None,
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
failure_files = list((temp_dir / "daily").glob("failure_*.jsonl"))
|
| 278 |
+
with open(failure_files[0]) as f:
|
| 279 |
+
event = json.loads(f.read().strip())
|
| 280 |
+
|
| 281 |
+
assert event["is_logged_in"] is False
|
| 282 |
+
assert event["hf_username"] is None
|
| 283 |
+
|
| 284 |
def test_error_message_sanitized(self, tracker, temp_dir):
|
| 285 |
"""Test that error messages are sanitized."""
|
| 286 |
tracker.log_failure_event(
|
tests/telemetry/test_utils.py
CHANGED
|
@@ -302,3 +302,36 @@ class TestExtractUserInfo:
|
|
| 302 |
|
| 303 |
assert user_info.is_logged_in is True
|
| 304 |
assert user_info.username == "user-name_123"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
|
| 303 |
assert user_info.is_logged_in is True
|
| 304 |
assert user_info.username == "user-name_123"
|
| 305 |
+
|
| 306 |
+
def test_extract_user_info_jwt_on_behalf_of_without_user(self):
|
| 307 |
+
"""Test extraction when onBehalfOf exists but user key is missing."""
|
| 308 |
+
header = {"alg": "HS256", "typ": "JWT"}
|
| 309 |
+
payload = {"onBehalfOf": {"role": "admin"}} # No "user" key
|
| 310 |
+
|
| 311 |
+
header_b64 = base64.urlsafe_b64encode(json.dumps(header).encode()).decode()
|
| 312 |
+
payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
|
| 313 |
+
header_b64 = header_b64.rstrip("=")
|
| 314 |
+
payload_b64 = payload_b64.rstrip("=")
|
| 315 |
+
|
| 316 |
+
token = f"{header_b64}.{payload_b64}.fake_sig"
|
| 317 |
+
referer = f"https://huggingface.co/spaces/test/app?__sign={token}"
|
| 318 |
+
|
| 319 |
+
request = self._create_mock_request(referer)
|
| 320 |
+
user_info = extract_user_info(request, is_hf_spaces=True)
|
| 321 |
+
|
| 322 |
+
assert user_info.is_logged_in is False
|
| 323 |
+
assert user_info.username is None
|
| 324 |
+
|
| 325 |
+
def test_extract_user_info_malformed_jwt_logs_warning(self, caplog):
|
| 326 |
+
"""Test that malformed JWT triggers a warning log."""
|
| 327 |
+
import logging
|
| 328 |
+
|
| 329 |
+
referer = "https://huggingface.co/spaces/test/app?__sign=invalid.jwt.token"
|
| 330 |
+
request = self._create_mock_request(referer)
|
| 331 |
+
|
| 332 |
+
with caplog.at_level(logging.WARNING):
|
| 333 |
+
user_info = extract_user_info(request, is_hf_spaces=True)
|
| 334 |
+
|
| 335 |
+
assert user_info.is_logged_in is False
|
| 336 |
+
# loguru propagates to standard logging when caplog is used with propagate=True
|
| 337 |
+
# The function should log a warning about JWT decode failure
|