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