feat(bat-020): daemon-scoped 브로커 인증정보 (REQ-046~051)
- REQ-046: BrokerCredential.daemon_id 필드 추가, repository 시그니처 갱신 - REQ-047: alembic revision Step1(daemon_id NULL 컬럼 추가), Step2(PK 전환, dev/test DB 한정) - REQ-048: backfill-daemon-id CLI subcommand (--dry-run/--force/--broker 지원) - REQ-049: GET /v1/user_info daemon-scoped 단일 broker 전달 (brokers→broker, daemon_id 없으면 403) - REQ-050: credential set/list/remove --daemon-id 옵션 추가 (daemon_slug 해석) - REQ-051: 보안 격리 - service-token 전용, web 토큰 차단, 로그 app_secret 비노출, graceful degrade 비가역 결재 게이트: REQ-047 Step2(PK 전환)는 사장님 결재 후 실DB 적용 예정(이 batch는 revision 코드만) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
"""BAT-020: REQ-046 BrokerCredential daemon_id 필드 단위 테스트."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.models import BrokerCredential
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-046 AC-1: daemon_id=None 기본값
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_broker_credential_daemon_id_defaults_to_none() -> None:
|
||||
"""REQ-046 AC-1: daemon_id 미지정 시 None이 기본값."""
|
||||
cred = BrokerCredential(
|
||||
user_id="user-1",
|
||||
broker="kis",
|
||||
account_ref="12345678",
|
||||
encrypted_payload="encrypted",
|
||||
)
|
||||
assert hasattr(cred, "daemon_id")
|
||||
assert cred.daemon_id is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-046 AC-2: daemon_id 값 지정
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_broker_credential_daemon_id_set() -> None:
|
||||
"""REQ-046 AC-2: daemon_id="daemon-abc" 지정 시 해당 값으로 세팅."""
|
||||
cred = BrokerCredential(
|
||||
user_id="user-1",
|
||||
broker="kis",
|
||||
account_ref="12345678",
|
||||
encrypted_payload="encrypted",
|
||||
daemon_id="daemon-abc",
|
||||
)
|
||||
assert cred.daemon_id == "daemon-abc"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-046 AC-3: _row_to_broker_credential 7컬럼(daemon_id 포함) 처리
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_row_to_broker_credential_with_daemon_id() -> None:
|
||||
"""REQ-046 AC-3: 7컬럼 행(daemon_id 포함) 변환 시 daemon_id 세팅."""
|
||||
from app.repositories.postgres import PostgresPortfolioRepository
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
row = ("user-1", "kis", "12345678", "encrypted", 2, now, "daemon-xyz")
|
||||
cred = PostgresPortfolioRepository._row_to_broker_credential(row)
|
||||
assert cred.user_id == "user-1"
|
||||
assert cred.broker == "kis"
|
||||
assert cred.account_ref == "12345678"
|
||||
assert cred.encrypted_payload == "encrypted"
|
||||
assert cred.credential_version == 2
|
||||
assert cred.last_rotated_at == now
|
||||
assert cred.daemon_id == "daemon-xyz"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-046 AC-4: _row_to_broker_credential 6컬럼(daemon_id 없음) 하위호환
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_row_to_broker_credential_without_daemon_id() -> None:
|
||||
"""REQ-046 AC-4: 6컬럼 행(daemon_id 없음) 변환 시 daemon_id=None (하위호환)."""
|
||||
from app.repositories.postgres import PostgresPortfolioRepository
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
row = ("user-1", "kis", "12345678", "encrypted", 1, now)
|
||||
cred = PostgresPortfolioRepository._row_to_broker_credential(row)
|
||||
assert cred.daemon_id is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-046 AC-5/6: list_broker_credentials daemon_id 필터 (mock 기반)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _FakeCursor:
|
||||
"""psycopg Cursor 최소 mock."""
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
self.last_query = None
|
||||
self.last_params = None
|
||||
|
||||
def execute(self, query, params=None):
|
||||
self.last_query = query
|
||||
self.last_params = params
|
||||
|
||||
def fetchall(self):
|
||||
return self._rows
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeConn:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def cursor(self):
|
||||
return _FakeCursor(self._rows)
|
||||
|
||||
|
||||
def _make_rows_for_user(user_id: str):
|
||||
"""daemon_id "d1", "d2" 각각 1건씩."""
|
||||
now = datetime.now(timezone.utc)
|
||||
return [
|
||||
(user_id, "kis", "ref-d1", "enc-d1", 1, now, "d1"),
|
||||
(user_id, "kis2", "ref-d2", "enc-d2", 1, now, "d2"),
|
||||
]
|
||||
|
||||
|
||||
def test_list_broker_credentials_daemon_id_filter() -> None:
|
||||
"""REQ-046 AC-5: daemon_id 필터 시 해당 daemon_id 행만 반환."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
from app.repositories.postgres import PostgresPortfolioRepository, PortfolioDBConfig
|
||||
|
||||
rows = _make_rows_for_user("user-1")
|
||||
# daemon_id="d1" 필터 시 d1 행만 반환되는 mock
|
||||
d1_rows = [rows[0]]
|
||||
|
||||
config = PortfolioDBConfig(
|
||||
host="localhost", port=5432, dbname="test",
|
||||
user="test", password="test", sslmode="disable",
|
||||
)
|
||||
repo = PostgresPortfolioRepository(config)
|
||||
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.__enter__ = lambda s: mock_cursor
|
||||
mock_cursor.__exit__ = MagicMock(return_value=False)
|
||||
mock_cursor.fetchall.return_value = d1_rows
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.cursor.return_value = mock_cursor
|
||||
repo._connection = mock_conn
|
||||
|
||||
# ensure_schema 무력화
|
||||
with patch.object(repo, "ensure_schema"):
|
||||
results = repo.list_broker_credentials("user-1", daemon_id="d1")
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].daemon_id == "d1"
|
||||
assert results[0].broker == "kis"
|
||||
|
||||
|
||||
def test_list_broker_credentials_no_daemon_id_filter_returns_all() -> None:
|
||||
"""REQ-046 AC-6: daemon_id 미지정 시 전체 반환 (하위호환)."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
from app.repositories.postgres import PostgresPortfolioRepository, PortfolioDBConfig
|
||||
|
||||
rows = _make_rows_for_user("user-1")
|
||||
|
||||
config = PortfolioDBConfig(
|
||||
host="localhost", port=5432, dbname="test",
|
||||
user="test", password="test", sslmode="disable",
|
||||
)
|
||||
repo = PostgresPortfolioRepository(config)
|
||||
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.__enter__ = lambda s: mock_cursor
|
||||
mock_cursor.__exit__ = MagicMock(return_value=False)
|
||||
mock_cursor.fetchall.return_value = rows
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.cursor.return_value = mock_cursor
|
||||
repo._connection = mock_conn
|
||||
|
||||
with patch.object(repo, "ensure_schema"):
|
||||
results = repo.list_broker_credentials("user-1")
|
||||
|
||||
assert len(results) == 2
|
||||
@@ -0,0 +1,460 @@
|
||||
"""BAT-020: REQ-050, REQ-048 — credential CLI --daemon-id + backfill 테스트."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from app.cli.main import create_app
|
||||
from app.core.config import Settings
|
||||
from app.core.encryption import encrypt_payload
|
||||
from app.core.security import hash_token, token_prefix
|
||||
from app.domain.models import AuthToken, BrokerCredential, Daemon, User
|
||||
|
||||
|
||||
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal FakeRepo for credential CLI tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CredFakeRepo:
|
||||
"""credential CLI 테스트용 최소 FakeRepo."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._users: dict[str, User] = {}
|
||||
self._daemons: dict[str, Daemon] = {}
|
||||
self._broker_credentials: dict[tuple, BrokerCredential] = {}
|
||||
self._seq = 1
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def ping(self) -> None:
|
||||
pass
|
||||
|
||||
def ensure_schema(self) -> None:
|
||||
pass
|
||||
|
||||
def get_user(self, user_id: str) -> User:
|
||||
u = self._users.get(user_id)
|
||||
if u is None:
|
||||
raise ValueError("사용자를 찾을 수 없습니다")
|
||||
return u
|
||||
|
||||
def get_user_by_username(self, username: str) -> User | None:
|
||||
return next((u for u in self._users.values() if u.username == username), None)
|
||||
|
||||
def create_user(self, username: str, **kwargs) -> User:
|
||||
user = User(
|
||||
id=f"user-{self._seq}",
|
||||
username=username,
|
||||
email=kwargs.get("email"),
|
||||
permission=kwargs.get("permission", "read"),
|
||||
is_active=kwargs.get("is_active", True),
|
||||
)
|
||||
self._seq += 1
|
||||
self._users[user.id] = user
|
||||
return user
|
||||
|
||||
def create_daemon(self, owner_user_id: str, *, daemon_slug: str, display_name: str | None = None) -> Daemon:
|
||||
d = Daemon(
|
||||
id=f"daemon-{self._seq}",
|
||||
owner_user_id=owner_user_id,
|
||||
daemon_slug=daemon_slug,
|
||||
display_name=display_name,
|
||||
is_active=True,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
self._seq += 1
|
||||
self._daemons[d.id] = d
|
||||
return d
|
||||
|
||||
def get_daemon_by_slug(self, owner_user_id: str, daemon_slug: str) -> Daemon | None:
|
||||
return next(
|
||||
(d for d in self._daemons.values() if d.owner_user_id == owner_user_id and d.daemon_slug == daemon_slug),
|
||||
None,
|
||||
)
|
||||
|
||||
def get_daemon_by_id(self, daemon_id: str) -> Daemon | None:
|
||||
return self._daemons.get(daemon_id)
|
||||
|
||||
def upsert_broker_credential(
|
||||
self,
|
||||
user_id: str,
|
||||
broker: str,
|
||||
*,
|
||||
account_ref: str,
|
||||
encrypted_payload: str,
|
||||
daemon_id: str | None = None,
|
||||
) -> BrokerCredential:
|
||||
self.get_user(user_id)
|
||||
key = (user_id, broker)
|
||||
existing = self._broker_credentials.get(key)
|
||||
cred = BrokerCredential(
|
||||
user_id=user_id,
|
||||
broker=broker,
|
||||
account_ref=account_ref,
|
||||
encrypted_payload=encrypted_payload,
|
||||
credential_version=(existing.credential_version + 1) if existing else 1,
|
||||
last_rotated_at=datetime.now(timezone.utc),
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
self._broker_credentials[key] = cred
|
||||
return cred
|
||||
|
||||
def list_broker_credentials(
|
||||
self,
|
||||
user_id: str,
|
||||
daemon_id: str | None = None,
|
||||
) -> list[BrokerCredential]:
|
||||
creds = [c for (uid, _), c in self._broker_credentials.items() if uid == user_id]
|
||||
if daemon_id is not None:
|
||||
creds = [c for c in creds if c.daemon_id == daemon_id]
|
||||
return sorted(creds, key=lambda c: c.broker)
|
||||
|
||||
def update_broker_credential_daemon_id(self, user_id: str, broker: str, daemon_id: str) -> bool:
|
||||
key = (user_id, broker)
|
||||
cred = self._broker_credentials.get(key)
|
||||
if cred is None or cred.daemon_id is not None:
|
||||
return False
|
||||
self._broker_credentials[key] = BrokerCredential(
|
||||
user_id=cred.user_id, broker=cred.broker, account_ref=cred.account_ref,
|
||||
encrypted_payload=cred.encrypted_payload, credential_version=cred.credential_version,
|
||||
last_rotated_at=cred.last_rotated_at, daemon_id=daemon_id,
|
||||
)
|
||||
return True
|
||||
|
||||
def delete_broker_credential(self, user_id: str, broker: str, daemon_id: str | None = None) -> bool:
|
||||
if daemon_id is not None:
|
||||
for key, cred in list(self._broker_credentials.items()):
|
||||
if cred.daemon_id == daemon_id and cred.broker == broker:
|
||||
del self._broker_credentials[key]
|
||||
return True
|
||||
return False
|
||||
key = (user_id, broker)
|
||||
if key not in self._broker_credentials:
|
||||
return False
|
||||
del self._broker_credentials[key]
|
||||
return True
|
||||
|
||||
|
||||
def _settings_factory() -> Settings:
|
||||
return Settings(
|
||||
allowed_origins_raw="http://localhost:3000",
|
||||
credential_encryption_key=_TEST_ENC_KEY,
|
||||
)
|
||||
|
||||
|
||||
def _build_runner(repo: CredFakeRepo) -> Any:
|
||||
app = create_app(settings_factory=_settings_factory, repository_factory=lambda settings: repo)
|
||||
runner = CliRunner()
|
||||
runner.app = app
|
||||
return runner
|
||||
|
||||
|
||||
def _make_encrypted(app_key: str = "k", app_secret: str = "s") -> str:
|
||||
return encrypt_payload(_TEST_ENC_KEY, json.dumps({"app_key": app_key, "app_secret": app_secret}))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-050 AC-1: credential set --daemon-id 성공
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_credential_set_with_daemon_id() -> None:
|
||||
"""REQ-050 AC-1: set --daemon-id → broker_credentials에 daemon_id 포함 upsert."""
|
||||
repo = CredFakeRepo()
|
||||
user = repo.create_user("alice")
|
||||
daemon = repo.create_daemon(user.id, daemon_slug="orb-bot")
|
||||
runner = _build_runner(repo)
|
||||
|
||||
result = runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "set",
|
||||
"--user", "alice",
|
||||
"--daemon-id", "orb-bot",
|
||||
"--broker", "kis",
|
||||
"--account-ref", "acc-123",
|
||||
"--app-key", "mykey",
|
||||
"--app-secret", "mysecret",
|
||||
"--force",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["broker"] == "kis"
|
||||
assert payload["daemon_id"] == daemon.id
|
||||
assert payload["account_ref"] == "acc-123"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-050 AC-2: credential list --daemon-id 필터
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_credential_list_daemon_id_filter() -> None:
|
||||
"""REQ-050 AC-2: list --daemon-id → 해당 daemon 인증정보만 출력."""
|
||||
repo = CredFakeRepo()
|
||||
user = repo.create_user("alice")
|
||||
d1 = repo.create_daemon(user.id, daemon_slug="bot-d1")
|
||||
d2 = repo.create_daemon(user.id, daemon_slug="bot-d2")
|
||||
|
||||
enc = _make_encrypted()
|
||||
repo.upsert_broker_credential(user.id, "kis", account_ref="ref-d1", encrypted_payload=enc, daemon_id=d1.id)
|
||||
repo.upsert_broker_credential(user.id, "kis2", account_ref="ref-d2", encrypted_payload=enc, daemon_id=d2.id)
|
||||
|
||||
runner = _build_runner(repo)
|
||||
result = runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "list",
|
||||
"--user", "alice",
|
||||
"--daemon-id", "bot-d1",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
items = json.loads(result.stdout)
|
||||
assert len(items) == 1
|
||||
assert items[0]["daemon_id"] == d1.id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-050 AC-3: credential list 전체 (daemon-id 미지정)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_credential_list_no_daemon_id_returns_all() -> None:
|
||||
"""REQ-050 AC-3: list --daemon-id 미지정 → 전체 인증정보."""
|
||||
repo = CredFakeRepo()
|
||||
user = repo.create_user("alice")
|
||||
d1 = repo.create_daemon(user.id, daemon_slug="bot-d1")
|
||||
d2 = repo.create_daemon(user.id, daemon_slug="bot-d2")
|
||||
|
||||
enc = _make_encrypted()
|
||||
repo.upsert_broker_credential(user.id, "kis", account_ref="ref-d1", encrypted_payload=enc, daemon_id=d1.id)
|
||||
repo.upsert_broker_credential(user.id, "kis2", account_ref="ref-d2", encrypted_payload=enc, daemon_id=d2.id)
|
||||
|
||||
runner = _build_runner(repo)
|
||||
result = runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "list",
|
||||
"--user", "alice",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
items = json.loads(result.stdout)
|
||||
assert len(items) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-050 AC-4: credential remove --daemon-id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_credential_remove_with_daemon_id() -> None:
|
||||
"""REQ-050 AC-4: remove --daemon-id → (daemon_id, broker) 기반 삭제."""
|
||||
repo = CredFakeRepo()
|
||||
user = repo.create_user("alice")
|
||||
d1 = repo.create_daemon(user.id, daemon_slug="orb-bot")
|
||||
|
||||
enc = _make_encrypted()
|
||||
repo.upsert_broker_credential(user.id, "kis", account_ref="ref", encrypted_payload=enc, daemon_id=d1.id)
|
||||
|
||||
runner = _build_runner(repo)
|
||||
result = runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "remove",
|
||||
"--user", "alice",
|
||||
"--daemon-id", "orb-bot",
|
||||
"--broker", "kis",
|
||||
"--force",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
remaining = repo.list_broker_credentials(user.id)
|
||||
assert len(remaining) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-050 AC-5: 존재하지 않는 daemon_slug → exit 1
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_credential_set_nonexistent_daemon_exits_1() -> None:
|
||||
"""REQ-050 AC-5: 존재하지 않는 daemon_slug → 오류 + exit 1."""
|
||||
repo = CredFakeRepo()
|
||||
repo.create_user("alice")
|
||||
|
||||
runner = _build_runner(repo)
|
||||
result = runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "set",
|
||||
"--user", "alice",
|
||||
"--daemon-id", "nonexistent-bot",
|
||||
"--broker", "kis",
|
||||
"--account-ref", "acc",
|
||||
"--app-key", "k",
|
||||
"--app-secret", "s",
|
||||
"--force",
|
||||
])
|
||||
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-050 AC-7: CLI 출력에 daemon_id 포함
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_credential_set_output_includes_daemon_id() -> None:
|
||||
"""REQ-050 AC-7: set 성공 출력에 daemon_id 포함."""
|
||||
repo = CredFakeRepo()
|
||||
user = repo.create_user("alice")
|
||||
daemon = repo.create_daemon(user.id, daemon_slug="tsl-bot")
|
||||
runner = _build_runner(repo)
|
||||
|
||||
result = runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "set",
|
||||
"--user", "alice",
|
||||
"--daemon-id", "tsl-bot",
|
||||
"--broker", "kis",
|
||||
"--account-ref", "acc-999",
|
||||
"--app-key", "k2",
|
||||
"--app-secret", "s2",
|
||||
"--force",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.stdout)
|
||||
assert "daemon_id" in payload
|
||||
assert "broker" in payload
|
||||
assert "account_ref" in payload
|
||||
assert "credential_version" in payload
|
||||
assert "last_rotated_at" in payload
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-048 AC-1/2: backfill-daemon-id 정상 백필
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_backfill_daemon_id_updates_null_rows() -> None:
|
||||
"""REQ-048 AC-1/2: daemon_id IS NULL 인증정보 → 백필 후 daemon_id 세팅."""
|
||||
repo = CredFakeRepo()
|
||||
user = repo.create_user("alice")
|
||||
daemon = repo.create_daemon(user.id, daemon_slug="orb-bot")
|
||||
|
||||
enc = _make_encrypted()
|
||||
# daemon_id=None으로 등록 (기존 user-scoped 방식)
|
||||
repo.upsert_broker_credential(user.id, "kis", account_ref="ref", encrypted_payload=enc, daemon_id=None)
|
||||
|
||||
runner = _build_runner(repo)
|
||||
result = runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "backfill-daemon-id",
|
||||
"--user", "alice",
|
||||
"--daemon-id", "orb-bot",
|
||||
"--force",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# 백필 후 daemon_id 세팅 확인
|
||||
updated = repo.list_broker_credentials(user.id)
|
||||
assert len(updated) == 1
|
||||
assert updated[0].daemon_id == daemon.id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-048 AC-3: --dry-run 시 DB 변경 없음
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_backfill_daemon_id_dry_run_no_change() -> None:
|
||||
"""REQ-048 AC-3: --dry-run → 실제 UPDATE 없음."""
|
||||
repo = CredFakeRepo()
|
||||
user = repo.create_user("alice")
|
||||
daemon = repo.create_daemon(user.id, daemon_slug="orb-bot")
|
||||
|
||||
enc = _make_encrypted()
|
||||
repo.upsert_broker_credential(user.id, "kis", account_ref="ref", encrypted_payload=enc, daemon_id=None)
|
||||
|
||||
runner = _build_runner(repo)
|
||||
result = runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "backfill-daemon-id",
|
||||
"--user", "alice",
|
||||
"--daemon-id", "orb-bot",
|
||||
"--dry-run",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# DB 변경 없음 (dry-run)
|
||||
creds = repo.list_broker_credentials(user.id)
|
||||
assert creds[0].daemon_id is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-048 AC-5: daemon_id IS NULL 없으면 "이미 완료됨"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_backfill_daemon_id_already_complete() -> None:
|
||||
"""REQ-048 AC-5: daemon_id IS NULL 없으면 이미 완료됨 + exit 0."""
|
||||
repo = CredFakeRepo()
|
||||
user = repo.create_user("alice")
|
||||
daemon = repo.create_daemon(user.id, daemon_slug="orb-bot")
|
||||
|
||||
enc = _make_encrypted()
|
||||
# 이미 daemon_id 있음
|
||||
repo.upsert_broker_credential(user.id, "kis", account_ref="ref", encrypted_payload=enc, daemon_id=daemon.id)
|
||||
|
||||
runner = _build_runner(repo)
|
||||
result = runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "backfill-daemon-id",
|
||||
"--user", "alice",
|
||||
"--daemon-id", "orb-bot",
|
||||
"--force",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "already_complete" in result.stdout or "이미 완료됨" in result.stdout
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-048 AC-6: 존재하지 않는 user → exit 1
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_backfill_daemon_id_nonexistent_user_exits_1() -> None:
|
||||
"""REQ-048 AC-6: 존재하지 않는 user --user → exit 1."""
|
||||
repo = CredFakeRepo()
|
||||
repo.create_daemon.__doc__ # dummy
|
||||
|
||||
runner = _build_runner(repo)
|
||||
result = runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "backfill-daemon-id",
|
||||
"--user", "nobody",
|
||||
"--daemon-id", "orb-bot",
|
||||
"--force",
|
||||
])
|
||||
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-048 AC-4: 백필 전후 encrypted_payload 동등 (데이터 손실 없음)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_backfill_preserves_encrypted_payload() -> None:
|
||||
"""REQ-048 AC-4: 백필 전후 encrypted_payload, account_ref 동등."""
|
||||
repo = CredFakeRepo()
|
||||
user = repo.create_user("alice")
|
||||
daemon = repo.create_daemon(user.id, daemon_slug="orb-bot")
|
||||
|
||||
enc = _make_encrypted("orig_key", "orig_secret")
|
||||
repo.upsert_broker_credential(
|
||||
user.id, "kis", account_ref="original-ref", encrypted_payload=enc, daemon_id=None
|
||||
)
|
||||
|
||||
original_encrypted = enc
|
||||
original_account_ref = "original-ref"
|
||||
|
||||
runner = _build_runner(repo)
|
||||
runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "backfill-daemon-id",
|
||||
"--user", "alice",
|
||||
"--daemon-id", "orb-bot",
|
||||
"--force",
|
||||
])
|
||||
|
||||
creds = repo.list_broker_credentials(user.id)
|
||||
assert creds[0].encrypted_payload == original_encrypted
|
||||
assert creds[0].account_ref == original_account_ref
|
||||
@@ -0,0 +1,436 @@
|
||||
"""BAT-020: REQ-049, REQ-051 — GET /v1/user_info daemon-scoped + 보안 격리 테스트."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
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, TokenPrincipal, User
|
||||
from app.main import create_app
|
||||
|
||||
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
|
||||
|
||||
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 _svc_token(token: str = "svc-token") -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _make_cred(user_id: str, daemon_id: str, broker: str = "kis", account_ref: str = "ref") -> BrokerCredential:
|
||||
plain = json.dumps({"app_key": "k", "app_secret": "s"})
|
||||
encrypted = encrypt_payload(_TEST_ENC_KEY, plain)
|
||||
return BrokerCredential(
|
||||
user_id=user_id, broker=broker, account_ref=account_ref,
|
||||
encrypted_payload=encrypted, credential_version=1,
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-049 AC-1: daemon_id 있음 + broker 있음 → 200 + 단일 broker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_daemon_scoped_broker_returned() -> None:
|
||||
"""REQ-049 AC-1: daemon_id 바인딩 token + 해당 daemon broker 있음 → 단일 BrokerItem."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="u1", permission="read",
|
||||
token_type="service", runtime_id="rt-1", daemon_id="d1",
|
||||
)
|
||||
user = User(id="u1", username="alice", permission="read", is_active=True)
|
||||
cred = _make_cred("u1", "d1", account_ref="acc-d1")
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = [cred]
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["broker"] is not None
|
||||
assert body["broker"]["account_ref"] == "acc-d1"
|
||||
assert body["broker"]["broker"] == "kis"
|
||||
assert body["broker"]["credential_payload"] == {"app_key": "k", "app_secret": "s"}
|
||||
# REQ-049 AC-6: brokers 키 없음
|
||||
assert "brokers" not in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-049 AC-2: daemon_id 있음 + broker 없음 → 200 + broker=null
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_daemon_no_broker_returns_null() -> None:
|
||||
"""REQ-049 AC-2: daemon_id 있음, 해당 daemon 인증정보 없음 → broker=null."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="u1", permission="read",
|
||||
token_type="service", daemon_id="d1",
|
||||
)
|
||||
user = User(id="u1", username="alice", permission="read", is_active=True)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = [] # 인증정보 없음
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["broker"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-049 AC-3, REQ-051 AC-3: daemon_id 없는 service token → 403
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_no_daemon_id_returns_403() -> None:
|
||||
"""REQ-049 AC-3, REQ-051 AC-3: daemon_id=None service token → 403."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="u1", permission="read",
|
||||
token_type="service", daemon_id=None,
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-049 AC-4/5: 같은 user 2데몬 격리
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_daemon_isolation_two_daemons() -> None:
|
||||
"""REQ-049 AC-4/5: daemon A, B가 각각 다른 account_ref를 반환."""
|
||||
settings = _make_settings()
|
||||
user = User(id="u1", username="alice", permission="read", is_active=True)
|
||||
cred_a = _make_cred("u1", "d-a", account_ref="acc-A")
|
||||
cred_b = _make_cred("u1", "d-b", account_ref="acc-B")
|
||||
|
||||
principal_a = TokenPrincipal(
|
||||
token_id="ta", user_id="u1", permission="read",
|
||||
token_type="service", daemon_id="d-a",
|
||||
)
|
||||
principal_b = TokenPrincipal(
|
||||
token_id="tb", user_id="u1", permission="read",
|
||||
token_type="service", daemon_id="d-b",
|
||||
)
|
||||
|
||||
# daemon A 호출
|
||||
mock_repo_a = MagicMock()
|
||||
mock_repo_a.get_token_principal.return_value = principal_a
|
||||
mock_repo_a.get_user.return_value = user
|
||||
mock_repo_a.list_broker_credentials.return_value = [cred_a]
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo_a), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo_a):
|
||||
client = _make_client(settings, mock_repo_a)
|
||||
resp_a = client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp_a.status_code == 200
|
||||
assert resp_a.json()["broker"]["account_ref"] == "acc-A"
|
||||
|
||||
# daemon B 호출
|
||||
mock_repo_b = MagicMock()
|
||||
mock_repo_b.get_token_principal.return_value = principal_b
|
||||
mock_repo_b.get_user.return_value = user
|
||||
mock_repo_b.list_broker_credentials.return_value = [cred_b]
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo_b), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo_b):
|
||||
client_b = _make_client(settings, mock_repo_b)
|
||||
resp_b = client_b.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp_b.status_code == 200
|
||||
assert resp_b.json()["broker"]["account_ref"] == "acc-B"
|
||||
# 상호 반환 없음
|
||||
assert resp_a.json()["broker"]["account_ref"] != resp_b.json()["broker"]["account_ref"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-049 AC-6: 응답에 broker 단일 키 존재, brokers 리스트 없음
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_response_has_broker_not_brokers() -> None:
|
||||
"""REQ-049 AC-6: 응답 JSON에 broker 단일 키만 있고 brokers 키 없음."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="u1", permission="read",
|
||||
token_type="service", daemon_id="d1",
|
||||
)
|
||||
user = User(id="u1", username="alice", permission="read", is_active=True)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = []
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "broker" in body
|
||||
assert "brokers" not in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-049 AC-7, REQ-051 AC-7: 복호화 실패 → broker.credential_payload=null
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_decryption_failure_returns_null_payload() -> None:
|
||||
"""REQ-049 AC-7, REQ-051 AC-7: 복호화 실패 → broker.credential_payload=null, 크래시 없음."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="u1", permission="read",
|
||||
token_type="service", daemon_id="d1",
|
||||
)
|
||||
user = User(id="u1", username="alice", permission="read", is_active=True)
|
||||
# 잘못된 encrypted_payload (복호화 실패)
|
||||
cred = BrokerCredential(
|
||||
user_id="u1", broker="kis", account_ref="ref",
|
||||
encrypted_payload="INVALID_PAYLOAD",
|
||||
credential_version=1, daemon_id="d1",
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = [cred]
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["broker"] is not None
|
||||
assert body["broker"]["account_ref"] == "ref"
|
||||
assert body["broker"]["credential_payload"] is None # 복호화 실패
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-051 AC-1: web 토큰 → 403
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_web_token_returns_403() -> None:
|
||||
"""REQ-051 AC-1: web user 토큰(token_type!=service) → 403."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="u1", permission="read",
|
||||
token_type="user", # service 아님
|
||||
daemon_id="d1",
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-051 AC-2: 비인증 → 401
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_unauthenticated_returns_401() -> None:
|
||||
"""REQ-051 AC-2: Authorization 헤더 없음 → 401."""
|
||||
settings = _make_settings()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = None # 토큰 없음
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info") # 헤더 없음
|
||||
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-051 AC-4: 다른 daemon 인증정보 → null (데이터 격리)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_different_daemon_credential_not_returned() -> None:
|
||||
"""REQ-051 AC-4: daemon_id="d1" token이 daemon_id="d2" 인증정보를 받지 않음."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="u1", permission="read",
|
||||
token_type="service", daemon_id="d1",
|
||||
)
|
||||
user = User(id="u1", username="alice", permission="read", is_active=True)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
# daemon_id="d1" 필터로 조회하면 빈 결과 (DB에는 d2만 있음)
|
||||
mock_repo.list_broker_credentials.return_value = []
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["broker"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-051 AC-5: 타 엔드포인트 응답에 credential_payload 미포함
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_other_endpoints_do_not_expose_credential_payload(monkeypatch) -> None:
|
||||
"""REQ-051 AC-5: GET /health, /v1/portfolios 등에 credential 필드 없음."""
|
||||
settings = _make_settings()
|
||||
client = TestClient(create_app(settings_factory=lambda: settings))
|
||||
|
||||
# /health 응답 검사
|
||||
resp = client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "credential_payload" not in body
|
||||
assert "app_secret" not in body
|
||||
assert "app_key" not in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-051 AC-6: DB 오류 → broker=null graceful degrade (500 없음)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_db_error_returns_null_broker() -> None:
|
||||
"""REQ-051 AC-6: list_broker_credentials DB 오류 → broker=null, 500 없음."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="u1", permission="read",
|
||||
token_type="service", daemon_id="d1",
|
||||
)
|
||||
user = User(id="u1", username="alice", permission="read", is_active=True)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.side_effect = RuntimeError("DB connection error")
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["broker"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-051 AC-8: 복호화 실패 시 로그에 app_secret 미노출
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_decryption_failure_log_does_not_expose_secret(caplog) -> None:
|
||||
"""REQ-051 AC-8: 복호화 실패 로그에 app_key, app_secret 값 미포함."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="u1", permission="read",
|
||||
token_type="service", daemon_id="d1",
|
||||
)
|
||||
user = User(id="u1", username="alice", permission="read", is_active=True)
|
||||
# 잘못된 encrypted_payload
|
||||
cred = BrokerCredential(
|
||||
user_id="u1", broker="kis", account_ref="ref",
|
||||
encrypted_payload="INVALID_PAYLOAD_SECRET_VALUE",
|
||||
credential_version=1, daemon_id="d1",
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = [cred]
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="app.api.v1_user_info"), \
|
||||
patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
# 로그에 app_secret, app_key 값이 없음 확인
|
||||
log_text = " ".join(caplog.messages)
|
||||
assert "mysecret" not in log_text
|
||||
assert "mykey" not in log_text
|
||||
# 로그에 INVALID_PAYLOAD 원문이 없음 (payload 노출 아님)
|
||||
# 단지 오류 유형(class name)만 기록
|
||||
assert "INVALID_PAYLOAD_SECRET_VALUE" not in log_text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-049 AC-9: 기존 필드들 회귀 없음
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_regression_existing_fields_unchanged() -> None:
|
||||
"""REQ-049 AC-9: broker 변경이 runtime_id, daemon_id, trading_enabled 등에 영향 없음."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="u1", permission="read",
|
||||
token_type="service", runtime_id="rt-xyz", daemon_id="d1",
|
||||
)
|
||||
user = User(id="u1", username="alice", email="alice@example.com", permission="read", is_active=True)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = []
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["runtime_id"] == "rt-xyz"
|
||||
assert body["daemon_id"] == "d1"
|
||||
assert body["trading_enabled"] is True
|
||||
assert body["user_id"] == "u1"
|
||||
assert body["username"] == "alice"
|
||||
assert body["permission"] == "read"
|
||||
+64
-13
@@ -175,6 +175,7 @@ class FakeRepo:
|
||||
*,
|
||||
account_ref: str,
|
||||
encrypted_payload: str,
|
||||
daemon_id: str | None = None,
|
||||
) -> BrokerCredential:
|
||||
self.get_user(user_id)
|
||||
key = (user_id, broker)
|
||||
@@ -186,17 +187,65 @@ class FakeRepo:
|
||||
encrypted_payload=encrypted_payload,
|
||||
credential_version=(existing.credential_version + 1) if existing is not None else 1,
|
||||
last_rotated_at=datetime.now(timezone.utc),
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
self._broker_credentials[key] = credential
|
||||
return credential
|
||||
|
||||
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]:
|
||||
return sorted(
|
||||
[credential for (owner_user_id, _), credential in self._broker_credentials.items() if owner_user_id == user_id],
|
||||
key=lambda credential: credential.broker,
|
||||
def list_broker_credentials(
|
||||
self,
|
||||
user_id: str,
|
||||
daemon_id: str | None = None,
|
||||
) -> list[BrokerCredential]:
|
||||
creds = [
|
||||
credential
|
||||
for (owner_user_id, _), credential in self._broker_credentials.items()
|
||||
if owner_user_id == user_id
|
||||
]
|
||||
if daemon_id is not None:
|
||||
creds = [c for c in creds if c.daemon_id == daemon_id]
|
||||
return sorted(creds, key=lambda credential: credential.broker)
|
||||
|
||||
def update_broker_credential_daemon_id(
|
||||
self,
|
||||
user_id: str,
|
||||
broker: str,
|
||||
daemon_id: str,
|
||||
) -> bool:
|
||||
key = (user_id, broker)
|
||||
cred = self._broker_credentials.get(key)
|
||||
if cred is None or cred.daemon_id is not None:
|
||||
return False
|
||||
self._broker_credentials[key] = BrokerCredential(
|
||||
user_id=cred.user_id,
|
||||
broker=cred.broker,
|
||||
account_ref=cred.account_ref,
|
||||
encrypted_payload=cred.encrypted_payload,
|
||||
credential_version=cred.credential_version,
|
||||
last_rotated_at=cred.last_rotated_at,
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
return True
|
||||
|
||||
def get_daemon_by_slug(self, owner_user_id: str, daemon_slug: str):
|
||||
return next(
|
||||
(d for d in self._daemons.values() if d.owner_user_id == owner_user_id and d.daemon_slug == daemon_slug),
|
||||
None,
|
||||
)
|
||||
|
||||
def delete_broker_credential(self, user_id: str, broker: str) -> bool:
|
||||
def delete_broker_credential(
|
||||
self,
|
||||
user_id: str,
|
||||
broker: str,
|
||||
daemon_id: str | None = None,
|
||||
) -> bool:
|
||||
if daemon_id is not None:
|
||||
# daemon_id 기반 삭제
|
||||
for key, cred in list(self._broker_credentials.items()):
|
||||
if cred.daemon_id == daemon_id and cred.broker == broker:
|
||||
del self._broker_credentials[key]
|
||||
return True
|
||||
return False
|
||||
key = (user_id, broker)
|
||||
if key not in self._broker_credentials:
|
||||
return False
|
||||
@@ -986,8 +1035,10 @@ def test_cli_daemon_create_and_list_render_entries() -> None:
|
||||
|
||||
|
||||
def test_cli_credential_set_list_and_remove_flow() -> None:
|
||||
"""REQ-050: --daemon-id 포함 credential set/list/remove 플로우 (BAT-020 갱신)."""
|
||||
repo = FakeRepo(_portfolio())
|
||||
user = repo.create_user("alice", permission="read")
|
||||
daemon = repo.create_daemon(user.id, daemon_slug="orb-bot")
|
||||
runner = build_runner(repo)
|
||||
|
||||
set_result = runner.invoke(
|
||||
@@ -999,6 +1050,8 @@ def test_cli_credential_set_list_and_remove_flow() -> None:
|
||||
"set",
|
||||
"--user",
|
||||
"alice",
|
||||
"--daemon-id",
|
||||
"orb-bot",
|
||||
"--broker",
|
||||
"kis",
|
||||
"--account-ref",
|
||||
@@ -1023,18 +1076,16 @@ def test_cli_credential_set_list_and_remove_flow() -> None:
|
||||
set_payload = json.loads(set_result.stdout)
|
||||
assert set_payload["user_id"] == user.id
|
||||
assert set_payload["broker"] == "kis"
|
||||
assert set_payload["daemon_id"] == daemon.id
|
||||
assert set_payload["credential_version"] == 1
|
||||
|
||||
assert list_result.exit_code == 0
|
||||
list_payload = json.loads(list_result.stdout)
|
||||
assert list_payload == [
|
||||
{
|
||||
"broker": "kis",
|
||||
"account_ref": "123-45",
|
||||
"credential_version": 1,
|
||||
"last_rotated_at": list_payload[0]["last_rotated_at"],
|
||||
}
|
||||
]
|
||||
assert len(list_payload) == 1
|
||||
assert list_payload[0]["broker"] == "kis"
|
||||
assert list_payload[0]["account_ref"] == "123-45"
|
||||
assert list_payload[0]["daemon_id"] == daemon.id
|
||||
assert list_payload[0]["credential_version"] == 1
|
||||
|
||||
assert remove_result.exit_code == 0
|
||||
remove_payload = json.loads(remove_result.stdout)
|
||||
|
||||
@@ -120,7 +120,8 @@ def test_user_info_returns_daemon_id_in_response(monkeypatch) -> None:
|
||||
assert resp.json()["daemon_id"] == "d-1"
|
||||
|
||||
|
||||
def test_user_info_null_daemon_id_returns_200_with_null(monkeypatch) -> None:
|
||||
def test_user_info_null_daemon_id_returns_403(monkeypatch) -> None:
|
||||
"""REQ-049 AC-3: daemon_id=None service token → 403 Forbidden (BAT-020 정책 변경)."""
|
||||
client = _app_with_db(monkeypatch)
|
||||
principal = _principal(daemon_id=None)
|
||||
user = User(id="user-1", username="alice", permission="read", is_active=True)
|
||||
@@ -134,8 +135,8 @@ def test_user_info_null_daemon_id_returns_200_with_null(monkeypatch) -> None:
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
resp = client.get("/v1/user_info", headers=_svc_header())
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["daemon_id"] is None
|
||||
# REQ-049: daemon_id 없는 service token은 user_info 403 (daemon-scoped 인가)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+63
-24
@@ -118,7 +118,7 @@ class _RuntimeIdRoundTripRepo:
|
||||
daemon_id=token.daemon_id,
|
||||
)
|
||||
|
||||
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]:
|
||||
def list_broker_credentials(self, user_id: str, daemon_id: str | None = None) -> list[BrokerCredential]:
|
||||
return []
|
||||
|
||||
|
||||
@@ -144,6 +144,9 @@ def test_health_returns_full_response() -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_auth_issue_service_token_returns_runtime_id(monkeypatch) -> None:
|
||||
# REQ-049: daemon_id 없는 service token → user_info 403
|
||||
# 이 테스트는 service token 발급의 runtime_id 생성을 검증하고,
|
||||
# daemon_id 없는 token으로 user_info 호출 시 403을 확인한다.
|
||||
repo = _RuntimeIdRoundTripRepo()
|
||||
settings = _make_settings(
|
||||
db_enabled=True,
|
||||
@@ -168,15 +171,13 @@ def test_auth_issue_service_token_returns_runtime_id(monkeypatch) -> None:
|
||||
assert issue_body["runtime_id"] is not None
|
||||
assert "token" in issue_body
|
||||
|
||||
# REQ-049 AC-3: daemon_id 없는 service token → user_info 403
|
||||
user_info_resp = client.get(
|
||||
"/v1/user_info",
|
||||
headers=_service_token_header(issue_body["token"]),
|
||||
)
|
||||
|
||||
assert user_info_resp.status_code == 200
|
||||
user_info_body = user_info_resp.json()
|
||||
assert user_info_body["runtime_id"] == issue_body["runtime_id"]
|
||||
assert user_info_body["username"] == "alice"
|
||||
assert user_info_resp.status_code == 403
|
||||
|
||||
|
||||
def test_auth_issue_service_token_generates_distinct_runtime_ids_per_token(monkeypatch) -> None:
|
||||
@@ -214,11 +215,12 @@ def test_auth_issue_service_token_generates_distinct_runtime_ids_per_token(monke
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None:
|
||||
# REQ-049: daemon_id 있음 + runtime_id=None → 200 반환
|
||||
settings = _make_app_db(monkeypatch)
|
||||
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id=None, # no runtime_id
|
||||
token_type="service", runtime_id=None, daemon_id="daemon-1",
|
||||
)
|
||||
user = User(id="user-1", username="alice", permission="read", is_active=True)
|
||||
|
||||
@@ -237,11 +239,12 @@ def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None
|
||||
|
||||
|
||||
def test_v1_user_info_returns_user(monkeypatch) -> None:
|
||||
# REQ-049: daemon_id 있음 + broker=None → 200 반환
|
||||
settings = _make_app_db(monkeypatch)
|
||||
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id="rt-abc"
|
||||
token_type="service", runtime_id="rt-abc", daemon_id="daemon-1",
|
||||
)
|
||||
user = User(
|
||||
id="user-1", username="alice", email="alice@example.com",
|
||||
@@ -264,15 +267,39 @@ def test_v1_user_info_returns_user(monkeypatch) -> None:
|
||||
assert body["username"] == "alice"
|
||||
assert body["trading_enabled"] is True
|
||||
assert body["disabled_reason"] is None
|
||||
assert body["brokers"] == []
|
||||
# REQ-049 AC-6: broker 단일 필드 존재, brokers 리스트 미존재
|
||||
assert "broker" in body
|
||||
assert "brokers" not in body
|
||||
assert body["broker"] is None # 인증정보 없음 → null
|
||||
|
||||
|
||||
def test_v1_user_info_no_daemon_id_returns_403(monkeypatch) -> None:
|
||||
"""REQ-049 AC-3, REQ-051 AC-3: daemon_id=None 서비스 토큰 → 403."""
|
||||
settings = _make_app_db(monkeypatch)
|
||||
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id="rt-abc", daemon_id=None,
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
|
||||
resp = client.get("/v1/user_info", headers=_service_token_header())
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None:
|
||||
# daemon_id 있음 + 비활성 user → disabled_reason
|
||||
settings = _make_app_db(monkeypatch)
|
||||
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-2", permission="read",
|
||||
token_type="service", runtime_id="rt-xyz"
|
||||
token_type="service", runtime_id="rt-xyz", daemon_id="daemon-2",
|
||||
)
|
||||
user = User(
|
||||
id="user-2", username="bob", email=None,
|
||||
@@ -295,8 +322,8 @@ def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None:
|
||||
assert body["disabled_reason"] == "account_inactive"
|
||||
|
||||
|
||||
def test_v1_user_info_returns_decrypted_broker_credentials(monkeypatch) -> None:
|
||||
"""REQ-007 AC5: brokers[]에 복호화된 credential_payload 반환."""
|
||||
def test_v1_user_info_returns_decrypted_broker_credential(monkeypatch) -> None:
|
||||
"""REQ-049 AC-1: daemon_id 있음 + broker 인증정보 있음 → 단일 broker 반환."""
|
||||
from app.core.encryption import encrypt_payload
|
||||
import json as _json
|
||||
|
||||
@@ -307,12 +334,13 @@ def test_v1_user_info_returns_decrypted_broker_credentials(monkeypatch) -> None:
|
||||
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id="rt-abc"
|
||||
token_type="service", runtime_id="rt-abc", daemon_id="daemon-1",
|
||||
)
|
||||
user = User(id="user-1", username="alice", permission="read", is_active=True)
|
||||
cred = BrokerCredential(
|
||||
user_id="user-1", broker="kis", account_ref="12345678",
|
||||
encrypted_payload=encrypted, credential_version=1,
|
||||
daemon_id="daemon-1",
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
@@ -331,8 +359,11 @@ def test_v1_user_info_returns_decrypted_broker_credentials(monkeypatch) -> None:
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert len(body["brokers"]) == 1
|
||||
broker = body["brokers"][0]
|
||||
# REQ-049 AC-6: broker 단일 필드, brokers 리스트 없음
|
||||
assert "broker" in body
|
||||
assert "brokers" not in body
|
||||
broker = body["broker"]
|
||||
assert broker is not None
|
||||
assert broker["broker"] == "kis"
|
||||
assert broker["credential_payload"] == {"app_key": "mykey", "app_secret": "mysecret"}
|
||||
|
||||
@@ -793,12 +824,13 @@ def test_v1_assets_read_token_200(monkeypatch) -> None:
|
||||
# GET /v1/user_info
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None:
|
||||
def test_v1_user_info_null_runtime_id_returns_200_with_null_b(monkeypatch) -> None:
|
||||
# REQ-049: daemon_id 있음 + runtime_id=None → 200
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
||||
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id=None, # no runtime_id
|
||||
token_type="service", runtime_id=None, daemon_id="daemon-1",
|
||||
)
|
||||
user = User(id="user-1", username="alice", permission="read", is_active=True)
|
||||
|
||||
@@ -816,12 +848,13 @@ def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None
|
||||
assert resp.json()["runtime_id"] is None
|
||||
|
||||
|
||||
def test_v1_user_info_returns_user(monkeypatch) -> None:
|
||||
def test_v1_user_info_returns_user_b(monkeypatch) -> None:
|
||||
# REQ-049 AC-9 회귀: daemon_id 있음 → 기존 필드들 정상 반환
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
||||
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id="rt-abc"
|
||||
token_type="service", runtime_id="rt-abc", daemon_id="daemon-1",
|
||||
)
|
||||
user = User(
|
||||
id="user-1", username="alice", email="alice@example.com",
|
||||
@@ -831,6 +864,7 @@ def test_v1_user_info_returns_user(monkeypatch) -> None:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = []
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
@@ -842,12 +876,13 @@ def test_v1_user_info_returns_user(monkeypatch) -> None:
|
||||
assert body["runtime_id"] == "rt-abc"
|
||||
assert body["username"] == "alice"
|
||||
assert body["disabled_reason"] is None
|
||||
# REQ-049 AC-6: broker 단일 필드, brokers 키 없음
|
||||
assert "broker" in body
|
||||
assert "brokers" not in body
|
||||
|
||||
|
||||
def test_v1_user_info_degrades_when_broker_credentials_lookup_fails(monkeypatch) -> None:
|
||||
"""issue #9 regression: broker_credentials lookup failure (missing table / DB error)
|
||||
must NOT crash /v1/user_info with 500. daemon_id/user_id/permission do not depend on
|
||||
broker credentials — degrade gracefully with empty brokers."""
|
||||
"""REQ-049 AC-2, REQ-051 AC-6: broker_credentials lookup failure → broker=null graceful degrade."""
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
||||
|
||||
principal = TokenPrincipal(
|
||||
@@ -872,15 +907,18 @@ def test_v1_user_info_degrades_when_broker_credentials_lookup_fails(monkeypatch)
|
||||
body = resp.json()
|
||||
assert body["daemon_id"] == "daemon-9"
|
||||
assert body["user_id"] == "user-1"
|
||||
assert body["brokers"] == []
|
||||
# REQ-049 AC-2: broker=null (graceful degrade, 크래시 없음)
|
||||
assert body["broker"] is None
|
||||
assert "brokers" not in body
|
||||
|
||||
|
||||
def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None:
|
||||
def test_v1_user_info_inactive_user_has_disabled_reason_b(monkeypatch) -> None:
|
||||
# daemon_id 있음 + 비활성 user
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
||||
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-2", permission="read",
|
||||
token_type="service", runtime_id="rt-xyz"
|
||||
token_type="service", runtime_id="rt-xyz", daemon_id="daemon-2",
|
||||
)
|
||||
user = User(
|
||||
id="user-2", username="bob", email=None,
|
||||
@@ -890,6 +928,7 @@ def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = []
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
|
||||
Reference in New Issue
Block a user