"""Per-user API keys: encryption roundtrip, repository/service behavior, endpoint auth/ownership, and one end-to-end check that a user's own key is actually used (not just stored) for a real provider call.""" from __future__ import annotations import uuid import httpx import pytest import respx from fastapi.testclient import TestClient from app.core.config import get_settings from app.core.crypto import decrypt_secret, encrypt_secret from app.main import app from app.models.enums import ApiKeyProvider from app.repositories.user_api_key_repository import UserApiKeyRepository from app.repositories.user_repository import UserRepository from app.services import user_api_key_service def _unique_email() -> str: return f"user-{uuid.uuid4().hex[:12]}@example.com" def _register_and_login(client: TestClient) -> dict[str, str]: email = _unique_email() client.post( "/api/v1/auth/register", json={"email": email, "password": "correct-horse-1", "display_name": "T"}, ) tokens = client.post( "/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"} ).json() return {"Authorization": f"Bearer {tokens['access_token']}"} # --- Encryption -------------------------------------------------------- def test_encrypt_decrypt_roundtrip(): settings = get_settings() ciphertext = encrypt_secret("sk-real-secret-value", settings) assert ciphertext != "sk-real-secret-value" assert decrypt_secret(ciphertext, settings) == "sk-real-secret-value" def test_decrypt_with_wrong_key_raises(): settings = get_settings() other_key_settings = settings.model_copy( update={"api_key_encryption_secret": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="} ) ciphertext = encrypt_secret("sk-real-secret-value", settings) with pytest.raises(ValueError): decrypt_secret(ciphertext, other_key_settings) # --- Repository ---------------------------------------------------------- async def test_repository_upsert_then_get_then_delete(db_session): repo = UserApiKeyRepository(db_session) user_id = uuid.uuid4() await repo.upsert(user_id, ApiKeyProvider.ANTHROPIC, "encrypted-1") row = await repo.get(user_id, ApiKeyProvider.ANTHROPIC) assert row is not None assert row.encrypted_key == "encrypted-1" await repo.upsert(user_id, ApiKeyProvider.ANTHROPIC, "encrypted-2") row = await repo.get(user_id, ApiKeyProvider.ANTHROPIC) assert row.encrypted_key == "encrypted-2" # updated in place, not duplicated await repo.delete(user_id, ApiKeyProvider.ANTHROPIC) assert await repo.get(user_id, ApiKeyProvider.ANTHROPIC) is None # --- Service --------------------------------------------------------------- async def test_list_status_shows_all_four_providers_unconfigured_by_default(db_session): settings = get_settings() statuses = await user_api_key_service.list_status(db_session, uuid.uuid4(), settings) assert {s["provider"] for s in statuses} == { "anthropic", "brave_search", "ninjapear", "uspto", } assert all(s["configured"] is False for s in statuses) assert all(s["value"] is None for s in statuses) uspto = next(s for s in statuses if s["provider"] == "uspto") assert uspto["free"] is True assert uspto["requires_government_id"] is True async def test_set_key_then_list_status_shows_it_configured(db_session): settings = get_settings() user_id = uuid.uuid4() await user_api_key_service.set_key( db_session, user_id, ApiKeyProvider.ANTHROPIC, "sk-my-real-key", settings, client_ip="10.0.0.1", ) statuses = await user_api_key_service.list_status(db_session, user_id, settings) anthropic = next(s for s in statuses if s["provider"] == "anthropic") assert anthropic["configured"] is True assert anthropic["value"] == "sk-my-real-key" async def test_set_blank_key_clears_a_previously_set_one(db_session): settings = get_settings() user_id = uuid.uuid4() await user_api_key_service.set_key( db_session, user_id, ApiKeyProvider.ANTHROPIC, "sk-my-real-key", settings, client_ip="10.0.0.1", ) await user_api_key_service.set_key( db_session, user_id, ApiKeyProvider.ANTHROPIC, " ", settings, client_ip="10.0.0.1" ) statuses = await user_api_key_service.list_status(db_session, user_id, settings) anthropic = next(s for s in statuses if s["provider"] == "anthropic") assert anthropic["configured"] is False assert anthropic["value"] is None async def test_get_effective_settings_falls_back_to_global_when_unset(db_session): settings = get_settings().model_copy(update={"anthropic_api_key": "global-key"}) effective = await user_api_key_service.get_effective_settings( db_session, uuid.uuid4(), settings ) assert effective.anthropic_api_key == "global-key" async def test_get_effective_settings_overrides_only_the_providers_the_user_set(db_session): settings = get_settings().model_copy( update={"anthropic_api_key": "global-anthropic", "brave_search_api_key": "global-brave"} ) user_id = uuid.uuid4() await user_api_key_service.set_key( db_session, user_id, ApiKeyProvider.ANTHROPIC, "my-own-anthropic-key", settings, client_ip="10.0.0.1", ) effective = await user_api_key_service.get_effective_settings(db_session, user_id, settings) assert effective.anthropic_api_key == "my-own-anthropic-key" assert effective.brave_search_api_key == "global-brave" # untouched, no override set async def test_list_status_never_fetches_ninjapear_credits_itself(db_session): """list_status must never make its own live NinjaPear call - the frontend sources that number from /system/status's already-fetched ninjapear_credit_balance instead (see the Settings page's System configuration box), so credits is always None from this endpoint regardless of whether a key is configured.""" settings = get_settings() user_id = uuid.uuid4() await user_api_key_service.set_key( db_session, user_id, ApiKeyProvider.NINJAPEAR, "my-ninjapear-key", settings, client_ip="10.0.0.1", ) with respx.mock: # No mock registered for nubela.co - respx raises if anything tries # to call it, proving list_status makes no such request. statuses = await user_api_key_service.list_status(db_session, user_id, settings) ninjapear = next(s for s in statuses if s["provider"] == "ninjapear") assert ninjapear["configured"] is True assert ninjapear["credits"] is None # --- Endpoints --------------------------------------------------------- def test_list_user_api_keys_requires_auth(client: TestClient): resp = client.get("/api/v1/user-api-keys") assert resp.status_code == 401 def test_list_and_set_user_api_key_round_trip(client: TestClient): headers = _register_and_login(client) initial = client.get("/api/v1/user-api-keys", headers=headers) assert initial.status_code == 200 assert all(not s["configured"] for s in initial.json()) set_resp = client.put( "/api/v1/user-api-keys/anthropic", json={"key": "sk-set-via-api"}, headers=headers ) assert set_resp.status_code == 200 assert set_resp.json()["configured"] is True assert set_resp.json()["value"] == "sk-set-via-api" after = client.get("/api/v1/user-api-keys", headers=headers) anthropic = next(s for s in after.json() if s["provider"] == "anthropic") assert anthropic["configured"] is True assert anthropic["value"] == "sk-set-via-api" def test_updating_your_own_api_key_is_logged_to_account_activity(client: TestClient): headers = _register_and_login(client) client.put("/api/v1/user-api-keys/anthropic", json={"key": "sk-set-via-api"}, headers=headers) events = client.get("/api/v1/auth/security-events", headers=headers).json() assert any(e["event_type"] == "api_key_updated" for e in events) def test_set_user_api_key_rejects_unknown_provider(client: TestClient): headers = _register_and_login(client) resp = client.put( "/api/v1/user-api-keys/not-a-real-provider", json={"key": "x"}, headers=headers ) assert resp.status_code == 422 async def test_two_users_keys_are_fully_isolated(client: TestClient, db_session): headers_a = _register_and_login(client) headers_b = _register_and_login(client) client.put("/api/v1/user-api-keys/anthropic", json={"key": "a-key"}, headers=headers_a) b_keys = client.get("/api/v1/user-api-keys", headers=headers_b).json() anthropic_b = next(s for s in b_keys if s["provider"] == "anthropic") assert anthropic_b["configured"] is False assert anthropic_b["value"] is None # --- Provider wiring: the user's own key is actually used ------------------ async def test_discover_endpoint_uses_the_callers_own_anthropic_and_brave_keys( client: TestClient, db_session, monkeypatch ): """End-to-end proof this isn't just stored and ignored - the actual outbound Brave Search call for this request carries the user's own key, not the server's global one.""" monkeypatch.setattr("app.core.config.Settings.search_provider", "brave", raising=False) headers = _register_and_login(client) user = await UserRepository(db_session).get_by_email( client.get("/api/v1/auth/me", headers=headers).json()["email"] ) settings = get_settings() await user_api_key_service.set_key( db_session, user.id, ApiKeyProvider.BRAVE_SEARCH, "my-own-brave-key", settings, client_ip="10.0.0.1", ) seen_auth_tokens: list[str] = [] def _capture(request: httpx.Request) -> httpx.Response: seen_auth_tokens.append(request.headers.get("X-Subscription-Token", "")) return httpx.Response(200, json={"web": {"results": []}}) test_settings = get_settings().model_copy(update={"search_provider": "brave"}) app.dependency_overrides[get_settings] = lambda: test_settings try: with respx.mock: respx.get(url__regex=r"https://api\.search\.brave\.com/.*").mock(side_effect=_capture) client.post("/api/v1/companies/discover", json={"name": "Acme Corp"}, headers=headers) finally: app.dependency_overrides.pop(get_settings, None) assert "my-own-brave-key" in seen_auth_tokens