- 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
461 lines
16 KiB
Python
461 lines
16 KiB
Python
"""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
|