Files
portfolio-hub/tests/test_bat022_req052_credential_api.py
T
genichinandClaude Sonnet 4.6 d24058d863 feat(bat-022): daemon CRUD admin API + self-service credential management (REQ-052/053/054)
- POST/PATCH/deactivate/activate /v1/admin/daemons — admin-only daemon CRUD (REQ-053)
- GET/PUT/DELETE /v1/daemons/{daemon_id}/credential — user-owned scoped credential management (REQ-052)
- Owner re-assignment auto-invalidates BrokerCredential via update_daemon_owner() (REQ-053 AC-4)
- Deactivation auto-revokes service tokens via revoke_tokens_by_daemon_id() (REQ-022 reuse, REQ-053 AC-6)
- DELETE /v1/admin/daemons/{id} → 405 (hard delete not provided, REQ-053 AC-8)
- All responses exclude app_key/app_secret; Fernet encryption at rest (REQ-054)
- Service-token → 403 on credential endpoints (REQ-052 AC-10)
- Admin ownership scope enforced: admin cannot write other user's credential (REQ-052 AC-9)
- REQ-051 regression: 4 service-token guard tests pass (REQ-054 AC-7)
- 44 new tests, 565 total passed (521 baseline + 44 new), 0 failures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
2026-06-28 14:18:37 +09:00

517 lines
21 KiB
Python

"""BAT-022: REQ-052 — GET/PUT/DELETE /v1/daemons/{daemon_id}/credential (소유 스코프 인증정보 관리)."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.core.auth import AuthContext
from app.core.config import Settings, get_settings
from app.core.encryption import encrypt_payload
from app.domain.models import BrokerCredential, Daemon, TokenPrincipal, User
from app.main import create_app
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
OWNER_USER_ID = "user-owner"
OTHER_USER_ID = "user-other"
DAEMON_ID = "daemon-001"
BROKER = "kis"
ACCOUNT_REF = "acc-001"
APP_KEY = "my-app-key-12345"
APP_SECRET = "my-app-secret-xyz"
def _make_settings(**kwargs) -> Settings:
return Settings(
allowed_origins_raw="http://localhost:3000",
db_enabled=True,
credential_encryption_key=_TEST_ENC_KEY,
**kwargs,
)
def _make_client(settings: Settings, mock_repo) -> TestClient:
get_settings.cache_clear()
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
return TestClient(app)
def _auth_header(token: str = "user-token") -> dict:
return {"Authorization": f"Bearer {token}"}
def _make_daemon(
daemon_id: str = DAEMON_ID,
owner_user_id: str = OWNER_USER_ID,
is_active: bool = True,
) -> Daemon:
return Daemon(
id=daemon_id,
owner_user_id=owner_user_id,
daemon_slug="trex-orb",
display_name="ORB",
is_active=is_active,
)
def _make_cred(
daemon_id: str = DAEMON_ID,
account_ref: str = ACCOUNT_REF,
credential_version: int = 1,
) -> BrokerCredential:
payload_json = json.dumps({"app_key": APP_KEY, "app_secret": APP_SECRET})
encrypted = encrypt_payload(_TEST_ENC_KEY, payload_json)
return BrokerCredential(
user_id=OWNER_USER_ID,
broker=BROKER,
account_ref=account_ref,
encrypted_payload=encrypted,
credential_version=credential_version,
last_rotated_at=datetime(2026, 6, 28, 12, 0, 0, tzinfo=timezone.utc),
daemon_id=daemon_id,
)
def _make_user_principal(user_id: str = OWNER_USER_ID) -> TokenPrincipal:
return TokenPrincipal(
token_id="tok-1",
user_id=user_id,
permission="read",
token_type="user",
runtime_id=None,
daemon_id=None,
)
def _make_service_principal(user_id: str = OWNER_USER_ID) -> TokenPrincipal:
return TokenPrincipal(
token_id="svc-1",
user_id=user_id,
permission="read",
token_type="service",
runtime_id="rt-1",
daemon_id=DAEMON_ID,
)
def _make_admin_principal(user_id: str = "admin-user") -> TokenPrincipal:
return TokenPrincipal(
token_id="adm-1",
user_id=user_id,
permission="admin",
token_type="admin",
)
# ---------------------------------------------------------------------------
# REQ-052 AC-1: PUT → 200 + 메타데이터 (secret 미포함)
# ---------------------------------------------------------------------------
def test_put_credential_returns_meta_without_secret() -> None:
"""REQ-052 AC-1: user-token(소유자) PUT → 200 + 메타데이터, app_key/app_secret 없음."""
settings = _make_settings()
principal = _make_user_principal()
daemon = _make_daemon()
cred = _make_cred(credential_version=1)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
mock_repo.upsert_broker_credential.return_value = cred
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.put(
f"/v1/daemons/{DAEMON_ID}/credential",
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": ACCOUNT_REF, "broker": BROKER},
headers=_auth_header(),
)
assert resp.status_code == 200
body = resp.json()
# Metadata fields present
assert body["daemon_id"] == DAEMON_ID
assert body["broker"] == BROKER
assert body["account_ref"] == ACCOUNT_REF
assert body["has_credential"] is True
assert body["credential_version"] == 1
# Secret fields MUST NOT be in response (REQ-052 AC-1, REQ-054 AC-1)
assert "app_key" not in body
assert "app_secret" not in body
assert "encrypted_payload" not in body
# ---------------------------------------------------------------------------
# REQ-052 AC-2: 회전 시 credential_version 증가
# ---------------------------------------------------------------------------
def test_put_credential_rotation_increments_version() -> None:
"""REQ-052 AC-2: 동일 daemon 재PUT → credential_version 이전보다 1 증가."""
settings = _make_settings()
principal = _make_user_principal()
daemon = _make_daemon()
# Simulate rotation: upsert returns version=2
cred_v2 = _make_cred(credential_version=2)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
mock_repo.upsert_broker_credential.return_value = cred_v2
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.put(
f"/v1/daemons/{DAEMON_ID}/credential",
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": ACCOUNT_REF, "broker": BROKER},
headers=_auth_header(),
)
assert resp.status_code == 200
assert resp.json()["credential_version"] == 2
# upsert called with correct daemon_id
mock_repo.upsert_broker_credential.assert_called_once()
call_kwargs = mock_repo.upsert_broker_credential.call_args
assert call_kwargs.kwargs["daemon_id"] == DAEMON_ID
# ---------------------------------------------------------------------------
# REQ-052 AC-3: service-token /v1/user_info 정합 (CLI·API·service-token 경로 통일)
# AC-3 is integration-level: verified by REQ-054 AC-6 (service-token GET /v1/user_info after PUT)
# Here we verify upsert is called with the correct daemon_id + account_ref
# ---------------------------------------------------------------------------
def test_put_credential_upsert_called_with_correct_daemon_id() -> None:
"""REQ-052 AC-3 partial: PUT 이후 upsert에 daemon_id가 정확히 전달됨."""
settings = _make_settings()
principal = _make_user_principal()
daemon = _make_daemon()
cred = _make_cred(account_ref="my-account")
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
mock_repo.upsert_broker_credential.return_value = cred
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
client.put(
f"/v1/daemons/{DAEMON_ID}/credential",
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": "my-account", "broker": BROKER},
headers=_auth_header(),
)
mock_repo.upsert_broker_credential.assert_called_once()
kwargs = mock_repo.upsert_broker_credential.call_args.kwargs
assert kwargs["daemon_id"] == DAEMON_ID
assert kwargs["account_ref"] == "my-account"
# ---------------------------------------------------------------------------
# REQ-052 AC-4: GET → 200 + 메타데이터 (secret 미포함)
# ---------------------------------------------------------------------------
def test_get_credential_meta_returns_metadata_without_secret() -> None:
"""REQ-052 AC-4: user-token(소유자) GET → 200 + 메타데이터, secret 없음."""
settings = _make_settings()
principal = _make_user_principal()
daemon = _make_daemon()
cred = _make_cred()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
mock_repo.get_broker_credential_by_daemon.return_value = cred
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header())
assert resp.status_code == 200
body = resp.json()
assert body["broker"] == BROKER
assert body["account_ref"] == ACCOUNT_REF
assert body["has_credential"] is True
assert body["credential_version"] == 1
assert "app_key" not in body
assert "app_secret" not in body
assert "encrypted_payload" not in body
# ---------------------------------------------------------------------------
# REQ-052 AC-5: DELETE → 204, 이후 GET has_credential=False
# ---------------------------------------------------------------------------
def test_delete_credential_returns_204() -> None:
"""REQ-052 AC-5: DELETE → 204."""
settings = _make_settings()
principal = _make_user_principal()
daemon = _make_daemon()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
mock_repo.delete_broker_credential.return_value = True
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.delete(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header())
assert resp.status_code == 204
def test_get_after_delete_returns_has_credential_false() -> None:
"""REQ-052 AC-5: DELETE 이후 GET → has_credential=False."""
settings = _make_settings()
principal = _make_user_principal()
daemon = _make_daemon()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
mock_repo.get_broker_credential_by_daemon.return_value = None # deleted
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header())
assert resp.status_code == 200
assert resp.json()["has_credential"] is False
# ---------------------------------------------------------------------------
# REQ-052 AC-6: 타 사용자 토큰 → 403 (소유 스코프 음성)
# ---------------------------------------------------------------------------
def test_put_credential_other_user_returns_403() -> None:
"""REQ-052 AC-6: 다른 user의 토큰으로 PUT → 403."""
settings = _make_settings()
# OTHER user's principal
principal = _make_user_principal(user_id=OTHER_USER_ID)
daemon = _make_daemon(owner_user_id=OWNER_USER_ID) # owned by OWNER
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.put(
f"/v1/daemons/{DAEMON_ID}/credential",
json={"app_key": "k", "app_secret": "s", "account_ref": "ref", "broker": BROKER},
headers=_auth_header("other-token"),
)
assert resp.status_code == 403
def test_get_credential_other_user_returns_403() -> None:
"""REQ-052 AC-6: 다른 user의 토큰으로 GET → 403."""
settings = _make_settings()
principal = _make_user_principal(user_id=OTHER_USER_ID)
daemon = _make_daemon(owner_user_id=OWNER_USER_ID)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("other-token"))
assert resp.status_code == 403
def test_delete_credential_other_user_returns_403() -> None:
"""REQ-052 AC-6: 다른 user의 토큰으로 DELETE → 403."""
settings = _make_settings()
principal = _make_user_principal(user_id=OTHER_USER_ID)
daemon = _make_daemon(owner_user_id=OWNER_USER_ID)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = daemon
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.delete(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("other-token"))
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# REQ-052 AC-7: 비인증 → 401
# ---------------------------------------------------------------------------
def test_credential_no_token_returns_401() -> None:
"""REQ-052 AC-7: 인증 토큰 없이 호출 → 401."""
settings = _make_settings()
client = _make_client(settings, MagicMock())
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential")
assert resp.status_code == 401
resp2 = client.put(
f"/v1/daemons/{DAEMON_ID}/credential",
json={"app_key": "k", "app_secret": "s", "account_ref": "ref"},
)
assert resp2.status_code == 401
resp3 = client.delete(f"/v1/daemons/{DAEMON_ID}/credential")
assert resp3.status_code == 401
# ---------------------------------------------------------------------------
# REQ-052 AC-8: 미존재 daemon_id → 404
# ---------------------------------------------------------------------------
def test_credential_nonexistent_daemon_returns_404() -> None:
"""REQ-052 AC-8: 존재하지 않는 daemon_id → 404."""
settings = _make_settings()
principal = _make_user_principal()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = None # not found
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get(f"/v1/daemons/nonexistent/credential", headers=_auth_header())
assert resp.status_code == 404
def test_put_nonexistent_daemon_returns_404() -> None:
"""REQ-052 AC-8: PUT 미존재 daemon → 404."""
settings = _make_settings()
principal = _make_user_principal()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.get_daemon_by_id.return_value = None
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.put(
"/v1/daemons/nonexistent/credential",
json={"app_key": "k", "app_secret": "s", "account_ref": "ref"},
headers=_auth_header(),
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# REQ-052 AC-9: admin 토큰으로 타인 daemon 인증정보 write → 403
# ---------------------------------------------------------------------------
def test_admin_cannot_write_other_user_credential() -> None:
"""REQ-052 AC-9: admin role이라도 타인 소유 daemon write → 403."""
settings = _make_settings()
admin_principal = _make_admin_principal(user_id="admin-user")
daemon = _make_daemon(owner_user_id=OWNER_USER_ID) # owned by OWNER, not admin
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = admin_principal
mock_repo.get_daemon_by_id.return_value = daemon
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.put(
f"/v1/daemons/{DAEMON_ID}/credential",
json={"app_key": "k", "app_secret": "s", "account_ref": "ref"},
headers=_auth_header("admin-token"),
)
assert resp.status_code == 403
def test_admin_cannot_read_other_user_credential() -> None:
"""REQ-052 AC-9: admin role이라도 타인 소유 daemon GET → 403."""
settings = _make_settings()
admin_principal = _make_admin_principal(user_id="admin-user")
daemon = _make_daemon(owner_user_id=OWNER_USER_ID)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = admin_principal
mock_repo.get_daemon_by_id.return_value = daemon
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("admin-token"))
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# REQ-052 AC-10: service-token → 403
# ---------------------------------------------------------------------------
def test_service_token_credential_get_returns_403() -> None:
"""REQ-052 AC-10: service-token으로 GET /v1/daemons/{id}/credential → 403."""
settings = _make_settings()
svc_principal = _make_service_principal()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = svc_principal
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("svc-token"))
assert resp.status_code == 403
def test_service_token_credential_put_returns_403() -> None:
"""REQ-052 AC-10: service-token으로 PUT → 403."""
settings = _make_settings()
svc_principal = _make_service_principal()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = svc_principal
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.put(
f"/v1/daemons/{DAEMON_ID}/credential",
json={"app_key": "k", "app_secret": "s", "account_ref": "ref"},
headers=_auth_header("svc-token"),
)
assert resp.status_code == 403
def test_service_token_credential_delete_returns_403() -> None:
"""REQ-052 AC-10: service-token으로 DELETE → 403."""
settings = _make_settings()
svc_principal = _make_service_principal()
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = svc_principal
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings, mock_repo)
resp = client.delete(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("svc-token"))
assert resp.status_code == 403