Add PKCE OAuth2 login against EVE SSO with an encrypted, auto-refreshing token store, driven by `familiar login` / `status` / `logout` CLI commands. The MCP server gains authenticated account tools: - account_status, get_wallet, get_skills, get_skill_queue - get_location, get_clones, get_assets (paginated) ESI client extended with Bearer auth and X-Pages pagination. 51 tests (auth + account mocked), coverage 88%. Client id/callback/scopes are env-overridable; refresh token Fernet-encrypted on disk. Closes #7 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
103 lines
3.3 KiB
Python
103 lines
3.3 KiB
Python
"""Authenticated account tools (auth mocked, ESI mocked)."""
|
|
|
|
import asyncio
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from familiar import account, auth
|
|
from familiar.server import mcp
|
|
from conftest import route
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_auth(monkeypatch):
|
|
async def _stub():
|
|
return "fake-token", 99
|
|
|
|
monkeypatch.setattr(auth, "get_auth", _stub)
|
|
return 99
|
|
|
|
|
|
def test_get_wallet(mock_esi, fake_auth):
|
|
mock_esi(lambda req: httpx.Response(200, json=1234567.89))
|
|
result = asyncio.run(account.get_wallet())
|
|
assert result["character_id"] == 99
|
|
assert result["balance"] == 1234567.89
|
|
|
|
|
|
def test_get_skills_summarizes(mock_esi, fake_auth):
|
|
body = {"total_sp": 5_000_000, "unallocated_sp": 10_000, "skills": [{}, {}, {}]}
|
|
mock_esi(lambda req: route(req, {"/skills/": body}))
|
|
result = asyncio.run(account.get_skills())
|
|
assert result["total_sp"] == 5_000_000
|
|
assert result["skills_trained"] == 3
|
|
|
|
|
|
def test_get_skill_queue_limits(mock_esi, fake_auth):
|
|
queue = [
|
|
{"skill_id": i, "finished_level": 5, "queue_position": i, "finish_date": "2026"}
|
|
for i in range(5)
|
|
]
|
|
mock_esi(lambda req: route(req, {"/skillqueue/": queue}))
|
|
result = asyncio.run(account.get_skill_queue(limit=2))
|
|
assert result["in_queue"] == 5
|
|
assert len(result["upcoming"]) == 2
|
|
|
|
|
|
def test_get_location_merges_endpoints(mock_esi, fake_auth):
|
|
fixtures = {
|
|
"/location/": {"solar_system_id": 30000142, "station_id": 60003760},
|
|
"/ship/": {"ship_type_id": 670, "ship_name": "My Capsule"},
|
|
"/online/": {"online": True, "last_login": "2026-06-27T00:00:00Z"},
|
|
}
|
|
mock_esi(lambda req: route(req, fixtures))
|
|
result = asyncio.run(account.get_location())
|
|
assert result["solar_system_id"] == 30000142
|
|
assert result["ship_name"] == "My Capsule"
|
|
assert result["online"] is True
|
|
|
|
|
|
def test_get_clones(mock_esi, fake_auth):
|
|
fixtures = {
|
|
"/clones/": {"home_location": {"location_id": 60003760}, "jump_clones": [{}, {}]},
|
|
"/implants/": [10000, 10001],
|
|
}
|
|
mock_esi(lambda req: route(req, fixtures))
|
|
result = asyncio.run(account.get_clones())
|
|
assert result["jump_clones"] == 2
|
|
assert result["active_implants"] == [10000, 10001]
|
|
|
|
|
|
def test_get_assets_paginates(mock_esi, fake_auth):
|
|
# Two pages of assets; the client should follow X-Pages and concatenate.
|
|
def handler(req):
|
|
page = req.url.params.get("page", "1")
|
|
if page == "1":
|
|
return httpx.Response(
|
|
200,
|
|
json=[{"type_id": 34, "quantity": 1, "location_id": 1}],
|
|
headers={"X-Pages": "2"},
|
|
)
|
|
return httpx.Response(
|
|
200,
|
|
json=[{"type_id": 35, "quantity": 2, "location_id": 1}],
|
|
headers={"X-Pages": "2"},
|
|
)
|
|
|
|
mock_esi(handler)
|
|
result = asyncio.run(account.get_assets(limit=20))
|
|
assert result["total_stacks"] == 2
|
|
assert {a["type_id"] for a in result["sample"]} == {34, 35}
|
|
|
|
|
|
def test_account_status_delegates(monkeypatch):
|
|
monkeypatch.setattr(auth, "status", lambda: {"authenticated": True, "character_id": 1})
|
|
result = asyncio.run(account.account_status())
|
|
assert result["authenticated"] is True
|
|
|
|
|
|
def test_account_tools_registered():
|
|
tools = {t.name for t in asyncio.run(mcp.list_tools())}
|
|
assert {"get_wallet", "get_skills", "get_location", "get_assets", "account_status"} <= tools
|