|
| 1 | +"""Tests that calendar_env UserManager does not leak access tokens to logs.""" |
| 2 | + |
| 3 | +import hashlib |
| 4 | +import importlib.util |
| 5 | +import logging |
| 6 | +import sys |
| 7 | +from pathlib import Path |
| 8 | +from unittest.mock import MagicMock |
| 9 | + |
| 10 | +import pytest |
| 11 | + |
| 12 | +REPO_ROOT = Path(__file__).resolve().parents[2] |
| 13 | +CALENDAR_SERVER = REPO_ROOT / "envs" / "calendar_env" / "server" |
| 14 | +USER_MANAGER_PATH = CALENDAR_SERVER / "database" / "managers" / "user_manager.py" |
| 15 | + |
| 16 | + |
| 17 | +def _load_user_manager_module(): |
| 18 | + """Import user_manager.py directly""" |
| 19 | + # Make `from database.x import ...` inside user_manager.py resolve. |
| 20 | + server_path = str(CALENDAR_SERVER) |
| 21 | + if server_path not in sys.path: |
| 22 | + sys.path.insert(0, server_path) |
| 23 | + |
| 24 | + spec = importlib.util.spec_from_file_location( |
| 25 | + "calendar_env_user_manager", str(USER_MANAGER_PATH) |
| 26 | + ) |
| 27 | + module = importlib.util.module_from_spec(spec) |
| 28 | + spec.loader.exec_module(module) |
| 29 | + return module |
| 30 | + |
| 31 | + |
| 32 | +@pytest.fixture(scope="module") |
| 33 | +def user_manager_module(): |
| 34 | + if not USER_MANAGER_PATH.exists(): |
| 35 | + pytest.skip("calendar_env user_manager.py not present in this checkout") |
| 36 | + try: |
| 37 | + return _load_user_manager_module() |
| 38 | + except Exception as exc: # pragma: no cover - import-time failure |
| 39 | + pytest.skip(f"calendar_env server deps unavailable: {exc}") |
| 40 | + |
| 41 | + |
| 42 | +def test_token_fingerprint_is_deterministic_short_sha256(user_manager_module): |
| 43 | + """Fingerprint must be the first 8 chars of sha256(token), no exceptions.""" |
| 44 | + token = "ya29.A0ARrdaM-k9Vq7GzY2pL4mQf8sN1xT0bR3uHcJWv5yKzP6eF2" |
| 45 | + expected = hashlib.sha256(token.encode("utf-8")).hexdigest()[:8] |
| 46 | + |
| 47 | + fp = user_manager_module._token_fingerprint(token) |
| 48 | + |
| 49 | + assert fp == expected |
| 50 | + assert len(fp) == 8 |
| 51 | + assert token not in fp |
| 52 | + |
| 53 | + |
| 54 | +def test_token_fingerprint_handles_empty(user_manager_module): |
| 55 | + assert user_manager_module._token_fingerprint("") == "<empty>" |
| 56 | + assert user_manager_module._token_fingerprint(None) == "<empty>" |
| 57 | + |
| 58 | + |
| 59 | +def test_get_user_by_access_token_does_not_log_token( |
| 60 | + user_manager_module, monkeypatch, caplog |
| 61 | +): |
| 62 | + """When the DB session raises, the raw token must not appear in logs.""" |
| 63 | + UserManager = user_manager_module.UserManager |
| 64 | + token = "ya29.A0ARrdaM-SECRET-TOKEN-VALUE-do-not-leak" |
| 65 | + |
| 66 | + failing_session = MagicMock() |
| 67 | + failing_session.query.side_effect = RuntimeError("simulated DB failure") |
| 68 | + monkeypatch.setattr(user_manager_module, "get_session", lambda _id: failing_session) |
| 69 | + monkeypatch.setattr(user_manager_module, "init_database", lambda _id: None) |
| 70 | + |
| 71 | + manager = UserManager.__new__(UserManager) |
| 72 | + manager.database_id = "test-db" |
| 73 | + |
| 74 | + with caplog.at_level(logging.ERROR, logger=user_manager_module.__name__): |
| 75 | + with pytest.raises(RuntimeError, match="simulated DB failure"): |
| 76 | + manager.get_user_by_access_token(token) |
| 77 | + |
| 78 | + assert token not in caplog.text, "raw access token leaked into log output" |
| 79 | + expected_fp = hashlib.sha256(token.encode("utf-8")).hexdigest()[:8] |
| 80 | + assert f"fingerprint={expected_fp}" in caplog.text, ( |
| 81 | + "log line should include a token fingerprint for correlation" |
| 82 | + ) |
| 83 | + failing_session.close.assert_called_once() |
| 84 | + |
| 85 | + |
| 86 | +def test_get_user_by_access_token_does_not_leak_token_via_exception_str( |
| 87 | + user_manager_module, monkeypatch, caplog |
| 88 | +): |
| 89 | + """The token must not leak via str(exc) either. |
| 90 | +
|
| 91 | + SQLAlchemy StatementError / DBAPIError include the bound parameters dict |
| 92 | + in their string form, e.g.: |
| 93 | + '... [parameters: {"static_token_1": "ya29..."}] ...' |
| 94 | + A naive `logger.error(f"...: {e}")` would emit the raw token even with |
| 95 | + the f-string's other slots sanitized. The current implementation logs |
| 96 | + only `type(e).__name__`, so this test pins that behavior. |
| 97 | + """ |
| 98 | + UserManager = user_manager_module.UserManager |
| 99 | + token = "ya29.A0ARrdaM-SECRET-INSIDE-EXC-STR" |
| 100 | + |
| 101 | + class FakeStatementError(Exception): |
| 102 | + def __str__(self) -> str: |
| 103 | + return ( |
| 104 | + "(sqlite3.OperationalError) no such column " |
| 105 | + "[SQL: SELECT users.* FROM users WHERE users.static_token = ?] " |
| 106 | + f"[parameters: ({token!r},)]" |
| 107 | + ) |
| 108 | + |
| 109 | + failing_session = MagicMock() |
| 110 | + failing_session.query.side_effect = FakeStatementError() |
| 111 | + monkeypatch.setattr(user_manager_module, "get_session", lambda _id: failing_session) |
| 112 | + monkeypatch.setattr(user_manager_module, "init_database", lambda _id: None) |
| 113 | + |
| 114 | + manager = UserManager.__new__(UserManager) |
| 115 | + manager.database_id = "test-db" |
| 116 | + |
| 117 | + with caplog.at_level(logging.ERROR, logger=user_manager_module.__name__): |
| 118 | + with pytest.raises(FakeStatementError): |
| 119 | + manager.get_user_by_access_token(token) |
| 120 | + |
| 121 | + assert token not in caplog.text, "raw token leaked via str(exception) in log output" |
| 122 | + expected_fp = hashlib.sha256(token.encode("utf-8")).hexdigest()[:8] |
| 123 | + assert f"fingerprint={expected_fp}" in caplog.text |
| 124 | + assert "FakeStatementError" in caplog.text, ( |
| 125 | + "log should still identify the exception type for diagnostics" |
| 126 | + ) |
| 127 | + assert "[parameters:" not in caplog.text, ( |
| 128 | + "the SQLAlchemy-style parameter dump must not reach the log sink" |
| 129 | + ) |
| 130 | + |
| 131 | + |
| 132 | +def test_get_user_by_access_token_propagates_original_exception( |
| 133 | + user_manager_module, monkeypatch |
| 134 | +): |
| 135 | + """Sanity: redacting the log line must not swallow the underlying error.""" |
| 136 | + UserManager = user_manager_module.UserManager |
| 137 | + |
| 138 | + failing_session = MagicMock() |
| 139 | + failing_session.query.side_effect = ValueError("boom") |
| 140 | + monkeypatch.setattr(user_manager_module, "get_session", lambda _id: failing_session) |
| 141 | + monkeypatch.setattr(user_manager_module, "init_database", lambda _id: None) |
| 142 | + |
| 143 | + manager = UserManager.__new__(UserManager) |
| 144 | + manager.database_id = "test-db" |
| 145 | + |
| 146 | + with pytest.raises(ValueError, match="boom"): |
| 147 | + manager.get_user_by_access_token("any-token") |
0 commit comments