Multi-character support across accounts
Rework the token store to hold many characters keyed by character_id,
migrating the previous single-character file transparently. `familiar login`
now adds a character; `familiar status` lists all; `familiar logout [name|id]`
removes one or all.
Every account tool gains an optional `character` (name or id): omitted means
all logged-in characters, given means just that one. Results are uniform
{"characters": [...]}. get_auth refreshes per character.
58 tests, coverage 89%. Closes #9
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
5 files changed
+379
-210
No files matched your search
+78
-44
@@ -1,100 +1,134 @@
|
||||
"""Authenticated account tools (auth mocked, ESI mocked)."""
|
||||
"""Authenticated account tools (auth mocked, ESI mocked), multi-character."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from conftest import route
|
||||
from familiar import account, auth
|
||||
from familiar.server import mcp
|
||||
from conftest import route
|
||||
|
||||
CHARS = [
|
||||
{"character_id": 99, "character_name": "Alpha", "scopes": []},
|
||||
{"character_id": 100, "character_name": "Beta", "scopes": []},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_auth(monkeypatch):
|
||||
async def _stub():
|
||||
return "fake-token", 99
|
||||
monkeypatch.setattr(auth, "list_characters", lambda: list(CHARS))
|
||||
|
||||
monkeypatch.setattr(auth, "get_auth", _stub)
|
||||
return 99
|
||||
async def get_auth(cid, **kw):
|
||||
return f"token-{cid}", cid
|
||||
|
||||
monkeypatch.setattr(auth, "get_auth", get_auth)
|
||||
|
||||
def resolve(identifier):
|
||||
for c in CHARS:
|
||||
if (
|
||||
str(c["character_id"]) == str(identifier)
|
||||
or (c["character_name"] or "").lower() == str(identifier).lower()
|
||||
):
|
||||
return c["character_id"]
|
||||
raise auth.NotAuthenticated("no such character")
|
||||
|
||||
monkeypatch.setattr(auth, "resolve_character", resolve)
|
||||
return CHARS
|
||||
|
||||
|
||||
def test_get_wallet(mock_esi, fake_auth):
|
||||
mock_esi(lambda req: httpx.Response(200, json=1234567.89))
|
||||
def test_wallet_all_characters(mock_esi, fake_auth):
|
||||
mock_esi(lambda req: httpx.Response(200, json=1000.0))
|
||||
result = asyncio.run(account.get_wallet())
|
||||
assert result["character_id"] == 99
|
||||
assert result["balance"] == 1234567.89
|
||||
assert len(result["characters"]) == 2
|
||||
assert {c["character_name"] for c in result["characters"]} == {"Alpha", "Beta"}
|
||||
assert all(c["balance"] == 1000.0 for c in result["characters"])
|
||||
|
||||
|
||||
def test_get_skills_summarizes(mock_esi, fake_auth):
|
||||
def test_wallet_single_character_by_name(mock_esi, fake_auth):
|
||||
mock_esi(lambda req: httpx.Response(200, json=500.0))
|
||||
result = asyncio.run(account.get_wallet(character="Alpha"))
|
||||
assert len(result["characters"]) == 1
|
||||
assert result["characters"][0]["character_id"] == 99
|
||||
|
||||
|
||||
def test_unknown_character_raises(mock_esi, fake_auth):
|
||||
mock_esi(lambda req: httpx.Response(200, json=1.0))
|
||||
with pytest.raises(auth.NotAuthenticated):
|
||||
asyncio.run(account.get_wallet(character="Ghost"))
|
||||
|
||||
|
||||
def test_not_logged_in_raises(monkeypatch):
|
||||
monkeypatch.setattr(auth, "list_characters", lambda: [])
|
||||
with pytest.raises(auth.NotAuthenticated):
|
||||
asyncio.run(account.get_wallet())
|
||||
|
||||
|
||||
def test_skills_summary(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
|
||||
result = asyncio.run(account.get_skills(character=99))
|
||||
assert result["characters"][0]["total_sp"] == 5_000_000
|
||||
assert result["characters"][0]["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)
|
||||
]
|
||||
def test_skill_queue_limit(mock_esi, fake_auth):
|
||||
queue = [{"skill_id": i, "finished_level": 5, "queue_position": i} 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
|
||||
result = asyncio.run(account.get_skill_queue(character="Beta", limit=2))
|
||||
entry = result["characters"][0]
|
||||
assert entry["in_queue"] == 5
|
||||
assert len(entry["upcoming"]) == 2
|
||||
|
||||
|
||||
def test_get_location_merges_endpoints(mock_esi, fake_auth):
|
||||
def test_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
|
||||
result = asyncio.run(account.get_location(character=99))
|
||||
entry = result["characters"][0]
|
||||
assert entry["solar_system_id"] == 30000142
|
||||
assert entry["ship_name"] == "My Capsule"
|
||||
assert entry["online"] is True
|
||||
|
||||
|
||||
def test_get_clones(mock_esi, fake_auth):
|
||||
def test_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]
|
||||
result = asyncio.run(account.get_clones(character=99))
|
||||
entry = result["characters"][0]
|
||||
assert entry["jump_clones"] == 2
|
||||
assert entry["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 test_assets_paginates(mock_esi, fake_auth):
|
||||
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"},
|
||||
)
|
||||
type_id = 34 if page == "1" else 35
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[{"type_id": 35, "quantity": 2, "location_id": 1}],
|
||||
json=[{"type_id": type_id, "quantity": 1, "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}
|
||||
result = asyncio.run(account.get_assets(character=99, limit=20))
|
||||
entry = result["characters"][0]
|
||||
assert entry["total_stacks"] == 2
|
||||
assert {a["type_id"] for a in entry["sample"]} == {34, 35}
|
||||
|
||||
|
||||
def test_account_status_delegates(monkeypatch):
|
||||
monkeypatch.setattr(auth, "status", lambda: {"authenticated": True, "character_id": 1})
|
||||
monkeypatch.setattr(auth, "status", lambda: {"authenticated": True, "characters": CHARS})
|
||||
result = asyncio.run(account.account_status())
|
||||
assert result["authenticated"] is True
|
||||
assert len(result["characters"]) == 2
|
||||
|
||||
|
||||
def test_account_tools_registered():
|
||||
|
||||
+69
-37
@@ -1,4 +1,4 @@
|
||||
"""EVE SSO auth: PKCE, JWT claims, token store, exchange/refresh, get_auth."""
|
||||
"""EVE SSO auth: PKCE, JWT claims, multi-character store, exchange/refresh."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
@@ -18,6 +18,19 @@ def tmp_store(tmp_path, monkeypatch):
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _char(cid, name, **extra):
|
||||
return {
|
||||
"refresh_token": f"r-{cid}",
|
||||
"access_token": f"a-{cid}",
|
||||
"expires_at": 10_000,
|
||||
"character_id": cid,
|
||||
"character_name": name,
|
||||
"scopes": [],
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
# --- pure helpers ---------------------------------------------------------- #
|
||||
def test_generate_pkce_challenge_matches_verifier():
|
||||
verifier, challenge = auth.generate_pkce()
|
||||
expected = (
|
||||
@@ -33,11 +46,10 @@ def test_build_authorize_url_has_required_params():
|
||||
assert "code_challenge=the-challenge" in url
|
||||
assert "code_challenge_method=S256" in url
|
||||
assert "state=the-state" in url
|
||||
assert "client_id=" in url
|
||||
|
||||
|
||||
def test_decode_jwt_and_character_id():
|
||||
token = make_jwt({"sub": "CHARACTER:EVE:2112000001", "name": "Latte", "scp": ["a"]})
|
||||
token = make_jwt({"sub": "CHARACTER:EVE:2112000001", "name": "Latte"})
|
||||
claims = auth.decode_jwt_claims(token)
|
||||
assert claims["name"] == "Latte"
|
||||
assert auth.character_id_from_claims(claims) == 2112000001
|
||||
@@ -53,28 +65,58 @@ def test_decode_malformed_token_raises():
|
||||
auth.decode_jwt_claims("not-a-jwt")
|
||||
|
||||
|
||||
def test_token_store_round_trip(tmp_store):
|
||||
bundle = {"refresh_token": "r", "character_id": 7, "character_name": "X"}
|
||||
assert auth.load_tokens() is None
|
||||
auth.save_tokens(bundle)
|
||||
assert auth.load_tokens() == bundle
|
||||
assert (tmp_store / "token.enc").exists()
|
||||
assert auth.clear_tokens() is True
|
||||
assert auth.load_tokens() is None
|
||||
assert auth.clear_tokens() is False
|
||||
# --- multi-character store ------------------------------------------------- #
|
||||
def test_store_starts_empty(tmp_store):
|
||||
assert auth.load_store() == {"characters": {}}
|
||||
assert auth.list_characters() == []
|
||||
|
||||
|
||||
def test_stored_token_is_encrypted_not_plaintext(tmp_store):
|
||||
auth.save_tokens({"refresh_token": "super-secret-value", "character_id": 1})
|
||||
def test_save_and_list_multiple_characters(tmp_store):
|
||||
auth._save_character(_char(1, "Beta"))
|
||||
auth._save_character(_char(2, "Alpha"))
|
||||
chars = auth.list_characters()
|
||||
assert [c["character_name"] for c in chars] == ["Alpha", "Beta"] # sorted by name
|
||||
|
||||
|
||||
def test_legacy_single_bundle_is_migrated(tmp_store):
|
||||
# Write a pre-multi-character file (single bundle at top level).
|
||||
auth._write_store({"refresh_token": "r", "character_id": 42, "character_name": "Old"})
|
||||
store = auth.load_store()
|
||||
assert "42" in store["characters"]
|
||||
assert auth.list_characters()[0]["character_name"] == "Old"
|
||||
|
||||
|
||||
def test_remove_character_by_name_and_all(tmp_store):
|
||||
auth._save_character(_char(1, "Beta"))
|
||||
auth._save_character(_char(2, "Alpha"))
|
||||
assert auth.remove_character("Alpha") is True
|
||||
assert {c["character_id"] for c in auth.list_characters()} == {1}
|
||||
assert auth.remove_character("nobody") is False
|
||||
assert auth.remove_character() is True # removes all
|
||||
assert auth.list_characters() == []
|
||||
assert auth.remove_character() is False
|
||||
|
||||
|
||||
def test_resolve_character_by_id_and_name(tmp_store):
|
||||
auth._save_character(_char(7, "Gamma"))
|
||||
assert auth.resolve_character(7) == 7
|
||||
assert auth.resolve_character("gamma") == 7
|
||||
with pytest.raises(auth.NotAuthenticated):
|
||||
auth.resolve_character("ghost")
|
||||
|
||||
|
||||
def test_stored_tokens_are_encrypted(tmp_store):
|
||||
auth._save_character(_char(1, "X", refresh_token="super-secret-value"))
|
||||
raw = (tmp_store / "token.enc").read_bytes()
|
||||
assert b"super-secret-value" not in raw
|
||||
|
||||
|
||||
# --- token requests + get_auth -------------------------------------------- #
|
||||
def _token_transport(payload):
|
||||
return httpx.MockTransport(lambda req: httpx.Response(200, json=payload))
|
||||
|
||||
|
||||
def test_exchange_code_posts_and_parses():
|
||||
def test_exchange_code_parses():
|
||||
payload = {"access_token": "a", "refresh_token": "r", "expires_in": 1200}
|
||||
out = asyncio.run(auth.exchange_code("code", "verifier", transport=_token_transport(payload)))
|
||||
assert out["refresh_token"] == "r"
|
||||
@@ -86,41 +128,31 @@ def test_token_request_raises_on_error():
|
||||
asyncio.run(auth.refresh_tokens("r", transport=transport))
|
||||
|
||||
|
||||
def test_get_auth_without_login_raises(tmp_store):
|
||||
def test_get_auth_unknown_character_raises(tmp_store):
|
||||
with pytest.raises(auth.NotAuthenticated):
|
||||
asyncio.run(auth.get_auth())
|
||||
asyncio.run(auth.get_auth(123))
|
||||
|
||||
|
||||
def test_get_auth_uses_unexpired_token(tmp_store):
|
||||
auth.save_tokens(
|
||||
{
|
||||
"access_token": "still-good",
|
||||
"refresh_token": "r",
|
||||
"character_id": 9,
|
||||
"expires_at": 10_000,
|
||||
}
|
||||
)
|
||||
token, cid = asyncio.run(auth.get_auth(now=5_000))
|
||||
auth._save_character(_char(9, "Z", access_token="still-good", expires_at=10_000))
|
||||
token, cid = asyncio.run(auth.get_auth(9, now=5_000))
|
||||
assert token == "still-good"
|
||||
assert cid == 9
|
||||
|
||||
|
||||
def test_get_auth_refreshes_when_expired(tmp_store):
|
||||
auth.save_tokens(
|
||||
{"access_token": "old", "refresh_token": "r0", "character_id": 9, "expires_at": 1_000}
|
||||
)
|
||||
new_access = make_jwt({"sub": "CHARACTER:EVE:9", "name": "Latte"})
|
||||
auth._save_character(_char(9, "Z", refresh_token="r0", expires_at=1_000))
|
||||
new_access = make_jwt({"sub": "CHARACTER:EVE:9", "name": "Z"})
|
||||
payload = {"access_token": new_access, "refresh_token": "r1", "expires_in": 1200}
|
||||
token, cid = asyncio.run(auth.get_auth(now=2_000, transport=_token_transport(payload)))
|
||||
token, cid = asyncio.run(auth.get_auth(9, now=2_000, transport=_token_transport(payload)))
|
||||
assert token == new_access
|
||||
assert cid == 9
|
||||
# The rotated refresh token is persisted.
|
||||
assert auth.load_tokens()["refresh_token"] == "r1"
|
||||
assert auth.load_store()["characters"]["9"]["refresh_token"] == "r1"
|
||||
|
||||
|
||||
def test_status_reflects_store(tmp_store):
|
||||
assert auth.status() == {"authenticated": False}
|
||||
auth.save_tokens({"refresh_token": "r", "character_id": 3, "character_name": "Y", "scopes": []})
|
||||
def test_status_lists_characters(tmp_store):
|
||||
assert auth.status() == {"authenticated": False, "characters": []}
|
||||
auth._save_character(_char(3, "Y"))
|
||||
info = auth.status()
|
||||
assert info["authenticated"] is True
|
||||
assert info["character_id"] == 3
|
||||
assert info["characters"][0]["character_id"] == 3
|
||||
Reference in new issue
Block a user