Files
CIAgent/apps/api/tests/unit/test_user_api_keys.py
T
saksham 1a4c80958f Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring,
source collection, LLM-based change analysis, enrichment, and account
security (Turnstile, escalating lockout, email verification).
2026-08-05 10:48:20 -04:00

290 lines
10 KiB
Python

"""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