File size: 8,024 Bytes
d66d64c
f9f5d3e
 
 
 
 
d66d64c
f9f5d3e
 
 
 
d66d64c
f9f5d3e
 
d66d64c
f9f5d3e
d66d64c
 
 
 
f9f5d3e
 
 
 
 
 
 
 
d66d64c
 
f9f5d3e
 
 
 
d66d64c
f9f5d3e
 
 
 
 
 
 
 
 
 
 
 
d66d64c
f9f5d3e
d66d64c
f9f5d3e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d66d64c
f9f5d3e
 
 
 
 
 
 
d66d64c
f9f5d3e
 
d66d64c
f9f5d3e
d66d64c
f9f5d3e
 
 
 
 
 
d66d64c
f9f5d3e
d66d64c
f9f5d3e
 
 
d66d64c
f9f5d3e
d66d64c
f9f5d3e
 
d66d64c
f9f5d3e
 
 
 
 
 
 
 
 
 
d66d64c
f9f5d3e
d66d64c
 
f9f5d3e
 
 
 
 
 
 
 
 
 
d66d64c
f9f5d3e
 
d66d64c
f9f5d3e
 
 
 
 
 
d66d64c
 
 
 
 
f9f5d3e
 
 
d66d64c
f9f5d3e
 
 
d66d64c
f9f5d3e
 
 
 
 
d66d64c
f9f5d3e
d66d64c
f9f5d3e
 
d66d64c
f9f5d3e
 
 
 
 
 
 
d66d64c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Tests for Anthropic startup authentication validation and health visibility.

These tests define the contract for startup credential validation and health visibility
before production implementation exists (RED state).

Contract under test:
(a) validate_anthropic_auth(timeout_s=5.0) when Anthropic returns HTTP 401:
    - returns a status representation with status == 'auth_failed'
    - logs/captures key suffix in '****last4' format
    - ensures the full fake key string never appears in logs or output
(b) GET /health when injected with cached auth state:
    - returns additive dictionary under 'anthropic' key: {status, key_suffix, checked_at}
    - contains only masked key suffix (e.g. '****1234')
    - ensures full fake key string never appears in response body
(c) validate_anthropic_auth(timeout_s=5.0) when Anthropic returns HTTP 200 / successful probe:
    - returns status == 'ok'
(d) validate_anthropic_auth(timeout_s=5.0) when the connection fails or times out:
    - returns status == 'connection_failed'
(e) validate_anthropic_auth(timeout_s=5.0) without a configured key:
    - returns status == 'unchecked' and does not contact Anthropic
"""

from __future__ import annotations

import logging
import os
from unittest.mock import MagicMock, patch

import anthropic
import httpx2
import pytest
from fastapi.testclient import TestClient

# Ensure test env vars match existing test harness conventions
os.environ["ANTHROPIC_API_KEY"] = "mock-key-for-testing"
os.environ["INFERENCE_MAX_RETRIES"] = "1"
os.environ["INFERENCE_BACKGROUND_MAX_RETRIES"] = "1"
os.environ["INFERENCE_INTERACTIVE_MAX_RETRIES"] = "1"
os.environ["INFERENCE_BACKOFF_SEC"] = "0"
os.environ["INFERENCE_BACKGROUND_BACKOFF_SEC"] = "0"

import main as main_module
from main import app

# Test client configured following existing backend test patterns
client = TestClient(app, headers={"Authorization": "Bearer mock_token_teacher_T"})

FAKE_API_KEY = "sk-fake-test-key-1234"
EXPECTED_MASKED_SUFFIX = "****1234"
PROBE_REQUEST = httpx2.Request("GET", "https://api.anthropic.com/v1/models")


@pytest.fixture(autouse=True)
def _setup_common_mocks():
    """Bypass Firebase token verification, time.sleep backoff, and curriculum retrieval."""
    with patch.object(
        main_module.firebase_auth,
        "verify_id_token",
        return_value={"uid": "teacher_T", "role": "teacher"},
    ), patch(
        "routes.rag_routes.retrieve_lesson_pdf_context",
        return_value=([], "mock_file"),
    ), patch(
        "time.sleep",
        return_value=None,
    ):
        yield


class TestStartupAuthVisibility:
    """Contract tests for Anthropic auth-visibility at startup and /health."""

    def test_mocked_401_startup_auth_check_yields_auth_failed_and_redacts_key(
        self, caplog: pytest.LogCaptureFixture
    ):
        """(a) Mocked-401 startup auth check yields 'auth_failed' with masked suffix and no raw key."""
        from startup_validation import (
            compute_key_fingerprint,
            validate_anthropic_auth,
        )

        api_error_401 = anthropic.AuthenticationError(
            f"Authentication failed for key {EXPECTED_MASKED_SUFFIX}",
            response=httpx2.Response(401, request=PROBE_REQUEST),
            body={"error": {"message": f"Invalid API key {EXPECTED_MASKED_SUFFIX}"}},
        )

        mock_client = MagicMock()
        mock_client.models.list.side_effect = api_error_401

        fingerprint = compute_key_fingerprint(FAKE_API_KEY)
        assert EXPECTED_MASKED_SUFFIX in fingerprint
        assert FAKE_API_KEY not in fingerprint

        caplog.clear()
        with caplog.at_level(logging.DEBUG), patch.dict(
            os.environ, {"ANTHROPIC_API_KEY": FAKE_API_KEY}
        ), patch(
            "services.ai_client.get_anthropic_client",
            return_value=mock_client,
        ):
            auth_status = validate_anthropic_auth(timeout_s=5.0)

        status_value = (
            auth_status.get("status")
            if isinstance(auth_status, dict)
            else getattr(auth_status, "status", None)
        )
        assert status_value == "auth_failed"

        captured_logs = caplog.text
        assert EXPECTED_MASKED_SUFFIX in captured_logs
        assert FAKE_API_KEY not in captured_logs

    def test_health_endpoint_returns_additive_cached_anthropic_auth_state(self):
        """(b) GET /health returns additive anthropic: {status, key_suffix, checked_at} with masked key."""
        import startup_validation

        injected_state = {
            "status": "auth_failed",
            "key_suffix": EXPECTED_MASKED_SUFFIX,
            "checked_at": "2026-09-22T00:00:00Z",
        }

        with patch.object(
            startup_validation,
            "cached_anthropic_auth_state",
            injected_state,
        ), patch.dict(
            os.environ, {"ANTHROPIC_API_KEY": FAKE_API_KEY}
        ):
            response = client.get("/health")

        assert response.status_code == 200
        response_body = response.json()

        assert "anthropic" in response_body
        anthropic_info = response_body["anthropic"]
        assert anthropic_info.get("status") == "auth_failed"
        assert anthropic_info.get("key_suffix") == EXPECTED_MASKED_SUFFIX
        assert "checked_at" in anthropic_info

        raw_response_text = response.text
        assert EXPECTED_MASKED_SUFFIX in raw_response_text
        assert FAKE_API_KEY not in raw_response_text

    def test_mocked_ok_startup_auth_check_yields_ok_status(self):
        """(c) Mocked-OK startup auth check yields status 'ok'."""
        from startup_validation import validate_anthropic_auth

        mock_client = MagicMock()
        mock_client.models.list.return_value = MagicMock()

        with patch.dict(
            os.environ, {"ANTHROPIC_API_KEY": FAKE_API_KEY}
        ), patch(
            "services.ai_client.get_anthropic_client",
            return_value=mock_client,
        ):
            auth_status = validate_anthropic_auth(timeout_s=5.0)

        status_value = (
            auth_status.get("status")
            if isinstance(auth_status, dict)
            else getattr(auth_status, "status", None)
        )
        assert status_value == "ok"
        mock_client.models.list.assert_called_once_with(limit=1, timeout=5.0)

    @pytest.mark.parametrize(
        "probe_error",
        [
            anthropic.APIConnectionError(request=PROBE_REQUEST),
            anthropic.APITimeoutError(request=PROBE_REQUEST),
            anthropic.InternalServerError(
                "overloaded", response=httpx2.Response(500, request=PROBE_REQUEST), body=None
            ),
        ],
        ids=["connection", "timeout", "server_error"],
    )
    def test_unreachable_api_yields_connection_failed(self, probe_error):
        """(d) A failing probe that is not an auth rejection reports 'connection_failed'."""
        from startup_validation import validate_anthropic_auth

        mock_client = MagicMock()
        mock_client.models.list.side_effect = probe_error

        with patch.dict(
            os.environ, {"ANTHROPIC_API_KEY": FAKE_API_KEY}
        ), patch(
            "services.ai_client.get_anthropic_client",
            return_value=mock_client,
        ):
            auth_status = validate_anthropic_auth(timeout_s=5.0)

        assert auth_status["status"] == "connection_failed"
        assert auth_status["key_suffix"] == EXPECTED_MASKED_SUFFIX

    def test_missing_key_yields_unchecked_without_probing(self):
        """(e) No configured key means nothing to probe: status 'unchecked'."""
        from startup_validation import validate_anthropic_auth

        with patch.dict(os.environ, {"ANTHROPIC_API_KEY": ""}), patch(
            "services.ai_client.get_anthropic_client"
        ) as get_client:
            auth_status = validate_anthropic_auth(timeout_s=5.0)

        assert auth_status["status"] == "unchecked"
        assert auth_status["key_suffix"] == "****"
        get_client.assert_not_called()