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:
2026-06-28 00:45:46 +09:00
co-authored by Claude Sonnet 4.6
parent fc99f507a5
commit 51b7cbee35
16 changed files with 1878 additions and 86 deletions
+34 -16
View File
@@ -31,41 +31,59 @@ def get_user_info(
auth: AuthContext = Depends(_require_service_token), auth: AuthContext = Depends(_require_service_token),
settings: Settings = Depends(get_settings), settings: Settings = Depends(get_settings),
) -> UserInfoResponse: ) -> UserInfoResponse:
# REQ-049, REQ-051: daemon_id 없으면 403 (daemon-scoped 인가 규율)
if auth.daemon_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="daemon_id binding required",
)
repo = get_portfolio_repository_from_settings(settings) repo = get_portfolio_repository_from_settings(settings)
try: try:
user = repo.get_user(auth.user_id) # type: ignore[arg-type] user = repo.get_user(auth.user_id) # type: ignore[arg-type]
# Fetch broker credentials and decrypt # REQ-049: daemon_id로 단일 broker 인증정보 조회 및 복호화
brokers: list[BrokerItem] = [] broker_item: BrokerItem | None = None
try: try:
credentials = repo.list_broker_credentials(auth.user_id) # type: ignore[arg-type] credentials = repo.list_broker_credentials( # type: ignore[call-arg]
enc_key: str | None = None auth.user_id, # type: ignore[arg-type]
for cred in credentials: daemon_id=auth.daemon_id,
)
if credentials:
cred = credentials[0]
credential_payload = None credential_payload = None
try: try:
if enc_key is None:
enc_key = settings.require_credential_encryption_key() enc_key = settings.require_credential_encryption_key()
raw = decrypt_payload(enc_key, cred.encrypted_payload) raw = decrypt_payload(enc_key, cred.encrypted_payload)
credential_payload = json.loads(raw) credential_payload = json.loads(raw)
except (InvalidToken, RuntimeError, Exception) as exc: except (InvalidToken, RuntimeError, Exception) as exc:
logger.warning("credential decryption failed for broker %s: %s", cred.broker, exc) # REQ-051 AC-8: 로그에 app_key, app_secret 값 미포함
brokers.append(BrokerItem( # exc는 Fernet 토큰 오류 메시지만 포함하며 credential 값을 담지 않는다.
logger.warning(
"credential decryption failed for broker %s daemon %s: %s",
cred.broker,
auth.daemon_id,
type(exc).__name__,
)
broker_item = BrokerItem(
broker=cred.broker, broker=cred.broker,
account_ref=cred.account_ref, account_ref=cred.account_ref,
credential_payload=credential_payload, credential_payload=credential_payload,
credential_version=cred.credential_version, credential_version=cred.credential_version,
last_rotated_at=cred.last_rotated_at, last_rotated_at=cred.last_rotated_at,
)) )
except AttributeError: except AttributeError:
# repo does not implement list_broker_credentials yet (mock or older impl) # repo does not implement list_broker_credentials yet (mock or older impl)
pass pass
except Exception as exc: # pragma: no cover - defensive except Exception as exc: # pragma: no cover - defensive
# Broker credentials are supplementary. A failure here (e.g. missing # REQ-049, REQ-051: broker 인증정보 조회 실패는 broker=null로 graceful degrade.
# broker_credentials table, DB error) must not crash /v1/user_info — # daemon_id, trading_enabled 필수 필드는 영향 없음.
# the critical fields (daemon_id, user_id, permission) do not depend logger.warning(
# on broker credentials. Degrade gracefully with an empty broker list. "broker credential lookup failed for daemon %s; returning broker=null: %s",
logger.warning("broker credential lookup failed; returning empty brokers: %s", exc) auth.daemon_id,
brokers = [] type(exc).__name__,
)
broker_item = None
finally: finally:
if callable(getattr(type(repo), "close", None)): if callable(getattr(type(repo), "close", None)):
@@ -80,7 +98,7 @@ def get_user_info(
daemon_id=auth.daemon_id, daemon_id=auth.daemon_id,
trading_enabled=user.is_active, trading_enabled=user.is_active,
disabled_reason=disabled_reason, disabled_reason=disabled_reason,
brokers=brokers, broker=broker_item,
user_id=user.id, user_id=user.id,
username=user.username, username=user.username,
email=user.email, email=user.email,
+111 -2
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Optional
import typer import typer
@@ -28,10 +29,25 @@ def _with_service(ctx: typer.Context, callback):
repository.close() repository.close()
def _resolve_daemon_id(repository, owner_user_id: str, daemon_id_input: str) -> str:
"""daemon_slug 또는 UUID로 daemon.id(UUID)를 반환한다. 없으면 ValueError."""
# 먼저 owner + slug로 시도
daemon = None
if hasattr(repository, "get_daemon_by_slug"):
daemon = repository.get_daemon_by_slug(owner_user_id, daemon_id_input)
# slug로 못 찾으면 UUID 직접 시도
if daemon is None and hasattr(repository, "get_daemon_by_id"):
daemon = repository.get_daemon_by_id(daemon_id_input)
if daemon is None:
raise ValueError(f"데몬을 찾을 수 없습니다: {daemon_id_input}")
return daemon.id
@credential_app.command("set") @credential_app.command("set")
def set_credential( def set_credential(
ctx: typer.Context, ctx: typer.Context,
user: str = typer.Option(..., "--user", help="Owner username (required)."), user: str = typer.Option(..., "--user", help="Owner username (required)."),
daemon_id_input: Optional[str] = typer.Option(None, "--daemon-id", help="Daemon slug or ID. If omitted, credential is stored without daemon binding (backfill required later)."),
broker: str = typer.Option("kis", "--broker", help="Broker code (default: kis)."), broker: str = typer.Option("kis", "--broker", help="Broker code (default: kis)."),
account_ref: str = typer.Option(..., "--account-ref", help="Account reference (e.g. account number)."), account_ref: str = typer.Option(..., "--account-ref", help="Account reference (e.g. account number)."),
app_key: str = typer.Option(..., "--app-key", help="Broker app_key."), app_key: str = typer.Option(..., "--app-key", help="Broker app_key."),
@@ -45,6 +61,16 @@ def set_credential(
owner = repository.get_user_by_username(user) owner = repository.get_user_by_username(user)
if owner is None: if owner is None:
raise ValueError(f"사용자를 찾을 수 없습니다: {user}") raise ValueError(f"사용자를 찾을 수 없습니다: {user}")
resolved_daemon_id: str | None = None
if daemon_id_input is not None:
resolved_daemon_id = _resolve_daemon_id(repository, owner.id, daemon_id_input)
else:
app_context.console.print(
"[yellow]WARN: --daemon-id 미지정. daemon_id 없이 등록된 인증정보는 "
"REQ-047 Step2 PK 전환 후 backfill이 필요합니다.[/yellow]"
)
enc_key = app_context.settings.require_credential_encryption_key() enc_key = app_context.settings.require_credential_encryption_key()
payload = json.dumps({"app_key": app_key, "app_secret": app_secret}) payload = json.dumps({"app_key": app_key, "app_secret": app_secret})
encrypted = encrypt_payload(enc_key, payload) encrypted = encrypt_payload(enc_key, payload)
@@ -53,6 +79,7 @@ def set_credential(
broker, broker,
account_ref=account_ref, account_ref=account_ref,
encrypted_payload=encrypted, encrypted_payload=encrypted,
daemon_id=resolved_daemon_id,
) )
result = _with_service(ctx, _callback) result = _with_service(ctx, _callback)
@@ -62,6 +89,7 @@ def set_credential(
ctx.obj.output_format, ctx.obj.output_format,
{ {
"user_id": result.user_id, "user_id": result.user_id,
"daemon_id": result.daemon_id,
"broker": result.broker, "broker": result.broker,
"account_ref": result.account_ref, "account_ref": result.account_ref,
"credential_version": result.credential_version, "credential_version": result.credential_version,
@@ -74,6 +102,7 @@ def set_credential(
def list_credentials( def list_credentials(
ctx: typer.Context, ctx: typer.Context,
user: str = typer.Option(..., "--user", help="Owner username (required)."), user: str = typer.Option(..., "--user", help="Owner username (required)."),
daemon_id_input: Optional[str] = typer.Option(None, "--daemon-id", help="Filter by daemon slug or ID (optional)."),
) -> None: ) -> None:
"""List broker credentials for a user (payload not shown).""" """List broker credentials for a user (payload not shown)."""
@@ -81,9 +110,15 @@ def list_credentials(
owner = repository.get_user_by_username(user) owner = repository.get_user_by_username(user)
if owner is None: if owner is None:
raise ValueError(f"사용자를 찾을 수 없습니다: {user}") raise ValueError(f"사용자를 찾을 수 없습니다: {user}")
records = repository.list_broker_credentials(owner.id)
resolved_daemon_id: str | None = None
if daemon_id_input is not None:
resolved_daemon_id = _resolve_daemon_id(repository, owner.id, daemon_id_input)
records = repository.list_broker_credentials(owner.id, daemon_id=resolved_daemon_id)
return [ return [
{ {
"daemon_id": r.daemon_id,
"broker": r.broker, "broker": r.broker,
"account_ref": r.account_ref, "account_ref": r.account_ref,
"credential_version": r.credential_version, "credential_version": r.credential_version,
@@ -107,6 +142,7 @@ def list_credentials(
def remove_credential( def remove_credential(
ctx: typer.Context, ctx: typer.Context,
user: str = typer.Option(..., "--user", help="Owner username (required)."), user: str = typer.Option(..., "--user", help="Owner username (required)."),
daemon_id_input: Optional[str] = typer.Option(None, "--daemon-id", help="Daemon slug or ID. If provided, deletes by (daemon_id, broker). Otherwise, deletes by (user_id, broker)."),
broker: str = typer.Option("kis", "--broker", help="Broker code (default: kis)."), broker: str = typer.Option("kis", "--broker", help="Broker code (default: kis)."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None: ) -> None:
@@ -117,7 +153,12 @@ def remove_credential(
owner = repository.get_user_by_username(user) owner = repository.get_user_by_username(user)
if owner is None: if owner is None:
raise ValueError(f"사용자를 찾을 수 없습니다: {user}") raise ValueError(f"사용자를 찾을 수 없습니다: {user}")
deleted = repository.delete_broker_credential(owner.id, broker)
resolved_daemon_id: str | None = None
if daemon_id_input is not None:
resolved_daemon_id = _resolve_daemon_id(repository, owner.id, daemon_id_input)
deleted = repository.delete_broker_credential(owner.id, broker, daemon_id=resolved_daemon_id)
if not deleted: if not deleted:
raise ValueError(f"인증 정보를 찾을 수 없습니다: user={user} broker={broker}") raise ValueError(f"인증 정보를 찾을 수 없습니다: user={user} broker={broker}")
return {"deleted": True, "user": user, "broker": broker} return {"deleted": True, "user": user, "broker": broker}
@@ -125,3 +166,71 @@ def remove_credential(
result = _with_service(ctx, _callback) result = _with_service(ctx, _callback)
if result is not None: if result is not None:
render_output(ctx.obj.console, ctx.obj.output_format, result) render_output(ctx.obj.console, ctx.obj.output_format, result)
@credential_app.command("backfill-daemon-id")
def backfill_daemon_id(
ctx: typer.Context,
user: str = typer.Option(..., "--user", help="Owner username (required)."),
daemon_id_input: str = typer.Option(..., "--daemon-id", help="Daemon slug or ID to assign to this user's NULL daemon_id credentials."),
broker: Optional[str] = typer.Option(None, "--broker", help="Specific broker to backfill (default: all NULL daemon_id credentials for the user)."),
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be updated without making changes."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None:
"""Backfill daemon_id for existing user-scoped broker credentials (REQ-048).
Sets daemon_id on broker_credentials rows where daemon_id IS NULL.
Run after REQ-047 Step1 migration and before Step2 PK conversion.
"""
def _callback(repository, app_context):
owner = repository.get_user_by_username(user)
if owner is None:
raise ValueError(f"사용자를 찾을 수 없습니다: {user}")
resolved_daemon_id = _resolve_daemon_id(repository, owner.id, daemon_id_input)
# daemon_id IS NULL인 인증정보 조회 (list_broker_credentials는 daemon_id 필터 없이 전체 조회)
# NULL인 행을 찾기 위해 전체 조회 후 필터
all_creds = repository.list_broker_credentials(owner.id)
null_creds = [c for c in all_creds if c.daemon_id is None]
if broker is not None:
null_creds = [c for c in null_creds if c.broker == broker]
if not null_creds:
app_context.console.print("이미 완료됨: daemon_id IS NULL인 인증정보가 없습니다.")
return {"status": "already_complete", "updated": 0}
if dry_run:
app_context.console.print(f"[dry-run] 대상 {len(null_creds)}건:")
for c in null_creds:
app_context.console.print(
f" user_id={c.user_id} broker={c.broker} → daemon_id={resolved_daemon_id}"
)
return {"status": "dry_run", "would_update": len(null_creds)}
_confirm(force, f"user '{user}'의 {len(null_creds)}건 인증정보에 daemon_id={resolved_daemon_id}를 설정하시겠습니까?")
updated = 0
for c in null_creds:
if hasattr(repository, "update_broker_credential_daemon_id"):
ok = repository.update_broker_credential_daemon_id(c.user_id, c.broker, resolved_daemon_id)
else:
# fallback: 직접 upsert (기존 payload 유지)
repository.upsert_broker_credential(
c.user_id,
c.broker,
account_ref=c.account_ref,
encrypted_payload=c.encrypted_payload,
daemon_id=resolved_daemon_id,
)
ok = True
if ok:
updated += 1
app_context.console.print(f"백필 완료: {updated}건 업데이트됨")
return {"status": "complete", "updated": updated}
result = _with_service(ctx, _callback)
if result is not None:
render_output(ctx.obj.console, ctx.obj.output_format, result)
+1
View File
@@ -182,4 +182,5 @@ class BrokerCredential:
encrypted_payload: str encrypted_payload: str
credential_version: int = 1 credential_version: int = 1
last_rotated_at: datetime | None = None last_rotated_at: datetime | None = None
daemon_id: str | None = None
+87 -2
View File
@@ -474,6 +474,7 @@ class PostgresPortfolioRepository:
encrypted_payload=row[3], encrypted_payload=row[3],
credential_version=int(row[4]) if len(row) > 4 else 1, credential_version=int(row[4]) if len(row) > 4 else 1,
last_rotated_at=row[5] if len(row) > 5 else None, last_rotated_at=row[5] if len(row) > 5 else None,
daemon_id=row[6] if len(row) > 6 else None,
) )
@staticmethod @staticmethod
@@ -739,12 +740,34 @@ class PostgresPortfolioRepository:
*, *,
account_ref: str, account_ref: str,
encrypted_payload: str, encrypted_payload: str,
daemon_id: str | None = None,
) -> BrokerCredential: ) -> BrokerCredential:
self.ensure_schema() self.ensure_schema()
with self._connect().cursor() as cur: with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,)) cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,))
if cur.fetchone() is None: if cur.fetchone() is None:
raise ValueError("사용자를 찾을 수 없습니다") raise ValueError("사용자를 찾을 수 없습니다")
# NOTE(REQ-046): daemon_id 컬럼은 REQ-047 Step1 마이그레이션 이후 존재.
# Step1 적용 전 환경에서는 daemon_id 컬럼이 없어 INSERT 실패 가능.
# Step2(PK 전환) 이후에는 ON CONFLICT 절도 (daemon_id, broker)로 변경 필요.
# 현재는 기존 PK (user_id, broker) ON CONFLICT 유지하고 daemon_id를 컬럼에 포함.
try:
cur.execute(
"""
INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload, daemon_id)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (user_id, broker) DO UPDATE
SET account_ref = EXCLUDED.account_ref,
encrypted_payload = EXCLUDED.encrypted_payload,
daemon_id = EXCLUDED.daemon_id,
credential_version = broker_credentials.credential_version + 1,
last_rotated_at = NOW()
RETURNING user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at, daemon_id
""",
(user_id, broker, account_ref, encrypted_payload, daemon_id),
)
except Exception:
# Fallback: daemon_id 컬럼이 아직 없는 환경(Step1 미적용)
cur.execute( cur.execute(
""" """
INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload) INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload)
@@ -761,9 +784,39 @@ class PostgresPortfolioRepository:
row = cur.fetchone() row = cur.fetchone()
return self._row_to_broker_credential(row) return self._row_to_broker_credential(row)
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]:
self.ensure_schema() self.ensure_schema()
with self._connect().cursor() as cur: with self._connect().cursor() as cur:
# NOTE(REQ-046): daemon_id 컬럼은 REQ-047 Step1 이후 존재.
# daemon_id 컬럼이 없는 환경(Step1 미적용)에서는 6컬럼 조회 fallback.
try:
if daemon_id is not None:
cur.execute(
"""
SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at, daemon_id
FROM broker_credentials
WHERE user_id = %s AND daemon_id = %s
ORDER BY broker ASC
""",
(user_id, daemon_id),
)
else:
cur.execute(
"""
SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at, daemon_id
FROM broker_credentials
WHERE user_id = %s
ORDER BY broker ASC
""",
(user_id,),
)
rows = cur.fetchall()
except Exception:
# Fallback: daemon_id 컬럼이 아직 없는 환경(Step1 미적용)
cur.execute( cur.execute(
""" """
SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
@@ -776,9 +829,41 @@ class PostgresPortfolioRepository:
rows = cur.fetchall() rows = cur.fetchall()
return [self._row_to_broker_credential(row) for row in rows] return [self._row_to_broker_credential(row) for row in rows]
def delete_broker_credential(self, user_id: str, broker: str) -> bool: def update_broker_credential_daemon_id(
self,
user_id: str,
broker: str,
daemon_id: str,
) -> bool:
"""백필용: 특정 (user_id, broker) 인증정보에 daemon_id를 설정한다 (REQ-048)."""
self.ensure_schema() self.ensure_schema()
with self._connect().cursor() as cur: with self._connect().cursor() as cur:
cur.execute(
"""
UPDATE broker_credentials
SET daemon_id = %s
WHERE user_id = %s AND broker = %s AND daemon_id IS NULL
RETURNING user_id
""",
(daemon_id, user_id, broker),
)
row = cur.fetchone()
return row is not None
def delete_broker_credential(
self,
user_id: str,
broker: str,
daemon_id: str | None = None,
) -> bool:
self.ensure_schema()
with self._connect().cursor() as cur:
if daemon_id is not None:
cur.execute(
"DELETE FROM broker_credentials WHERE daemon_id = %s AND broker = %s RETURNING user_id",
(daemon_id, broker),
)
else:
cur.execute( cur.execute(
"DELETE FROM broker_credentials WHERE user_id = %s AND broker = %s RETURNING user_id", "DELETE FROM broker_credentials WHERE user_id = %s AND broker = %s RETURNING user_id",
(user_id, broker), (user_id, broker),
+1 -1
View File
@@ -96,7 +96,7 @@ class UserInfoResponse(BaseModel):
daemon_id: str | None = None daemon_id: str | None = None
trading_enabled: bool trading_enabled: bool
disabled_reason: str | None = None disabled_reason: str | None = None
brokers: list[BrokerItem] = [] broker: BrokerItem | None = None
# Additional informational fields # Additional informational fields
user_id: str user_id: str
username: str username: str
@@ -0,0 +1,89 @@
# BAT-020 Design
## Design 위임 2건 확정
### D-1: REQ-048 백필 구현방식 — CLI subcommand vs alembic data migration revision
**결정: CLI subcommand (`pfhub credential backfill-daemon-id`)**
근거:
1. **운영자 제공 매핑 필요**: 어떤 user가 어떤 daemon_id에 매핑되는가는 인프라/운영 도메인 지식이 필요하며 자동화 불가 (REQ-048 Notes). alembic data migration revision은 매핑을 하드코딩하거나 env 변수에서 읽어야 해 유연성이 낮다.
2. **--dry-run 필수 (REQ-048 AC-3)**: CLI subcommand는 typer option으로 dry-run을 자연스럽게 표현하고, 운영자가 실행 전 확인 후 적용 가능. alembic revision에 dry-run을 구현하면 비표준 패턴이 된다.
3. **기존 CLI 패턴과 일관성**: 현재 `pfhub credential set/list/remove`가 이미 CLI로 구현돼 있으며, `--user` → user_id 변환 패턴을 그대로 활용 가능 (`get_user_by_username`).
4. **반복 실행 가능**: 1회성 운영 작업이지만 개발 환경에서 반복 실행 가능하도록 CLI가 더 적합. alembic은 이미 적용된 revision을 재실행하지 않아 개발 테스트에 불편.
구현: `pfhub credential backfill-daemon-id --user <username> --daemon-id <daemon_slug_or_id> [--dry-run] [--force]`
---
### D-2: REQ-050 --daemon-id 입력 타입 + 과도기 정책
**결정 (1): `--daemon-id`는 daemon_slug를 받는다 (UUID도 허용, 내부에서 해석)**
근거:
- 기존 `daemons` 테이블에 `daemon_slug` (TEXT, UNIQUE per owner)와 `id` (UUID TEXT) 두 식별자가 있다.
- 기존 CLI 패턴: `--user`가 username(사람이 읽기 쉬운 식별자)을 받아 내부에서 `get_user_by_username()`으로 user_id 변환. 이 패턴과 동일하게 `--daemon-id`도 daemon_slug를 우선 받는다.
- `get_daemon_by_slug(owner_user_id, daemon_slug)` 메서드가 이미 존재함 (postgres.py:2204). `--user`로 먼저 owner user를 조회하고, 그 user_id + daemon_slug로 daemon을 조회한다.
- UUID 형식이면 `get_daemon_by_id()`로도 조회 시도 가능하나, 이 batch에서는 **daemon_slug 우선 + user 소유 검증**으로 단순화한다. UUID 직접 지정은 미지원(오운 user에서 slug로 찾지 못하면 에러).
- 입력 파라미터 이름은 `--daemon-id`로 유지(REQ-050 명세)하되, 값은 daemon_slug로 해석.
**결정 (2): REQ-047 과도기(daemon_id nullable 구간)의 --daemon-id 필수/선택 정책**
- `pfhub credential set` — REQ-047 Step1 이후(nullable 구간): `--daemon-id` 미제공 시에도 upsert 허용 (기존 user-scoped 동작 유지). **단, 경고 메시지 출력**: "daemon_id 없이 등록된 인증정보는 REQ-047 Step2 PK 전환 후 backfill이 필요합니다."
- 이유: 단계적 마이그레이션 과도기에 운영자가 기존 방식으로도 인증정보를 등록할 수 있어야 함. Step2 PK 전환 이후에는 daemon_id NOT NULL 제약이 DB 레벨에서 강제된다.
- REQ-050 AC-6 ("--daemon-id 미제공 시 exit 1")은 REQ-047 Step2 PK 전환 완료 이후 상태 기준. 이 batch에서는 미제공 시 경고만 출력하고 허용. (AC-6은 Step2 이후 환경에서 DB 제약으로 자동 강제됨)
- `pfhub credential remove` — `--daemon-id` 제공 시 (daemon_id, broker) 기반 삭제, 미제공 시 기존 (user_id, broker) 기반 삭제 (하위 호환).
- `pfhub credential list` — `--daemon-id` 제공 시 해당 daemon_id 필터, 미제공 시 user 전체 (기존 동작).
---
## 주요 설계 결정
### DS-1: UserInfoResponse.brokers → broker 단일화 (REQ-049)
- `brokers: list[BrokerItem]` 필드 제거, `broker: BrokerItem | None = None` 추가
- 기존 소비자(trex-core hub_read.whoami)가 `daemon_id`만 읽고 `brokers` 미참조 → 파괴적 변경이나 실질 영향 없음 (dcy-010 OQ-2)
- 이 batch의 기존 테스트 중 `body["brokers"]`를 참조하는 테스트는 `body["broker"]`로 갱신 필요
### DS-2: daemon_id 없음 → 403 (REQ-049, REQ-051)
- `auth.daemon_id is None`이면 HTTP 403 Forbidden 반환
- 기존 `_require_service_token()` 함수는 token_type 검사만 함 → daemon_id 검사는 엔드포인트 핸들러 초입에서 추가
### DS-3: graceful degrade 계층 (REQ-049, REQ-051)
- `list_broker_credentials` DB 오류 → except → `broker=None` 반환 (기존 패턴 유지, 확장)
- 복호화 실패 → `credential_payload=None`인 BrokerItem 반환 (기존 패턴 유지)
- 어느 단계에서도 `app_key`, `app_secret` 값은 로그에 출력하지 않음
### DS-4: _row_to_broker_credential 하위호환 (REQ-046 AC-4)
- 기존 DB(daemon_id 컬럼 없음): 6컬럼 행 (user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at)
- REQ-047 Step1 이후: 7컬럼 행 (위 + daemon_id)
- `_row_to_broker_credential(row)` — `len(row) > 6`이면 `row[6]`을 daemon_id로 읽고, 없으면 None. 단, SELECT 쿼리에서 daemon_id를 명시적으로 포함하는 것이 더 안전. 따라서 쿼리에 daemon_id 컬럼을 추가하되, 컬럼 미존재 시 에러가 나지 않도록 Step1 revision 이후에만 활성화.
- 실용적 접근: 현재 `_SELECT_*` 상수 방식이 아니라 inline SQL이므로, `list_broker_credentials`에서 daemon_id를 선택적으로 포함하는 것이 복잡. 대신 `_row_to_broker_credential`에서 인덱스 기반 조회 시 `len(row) > 6`으로 분기.
- 단, `list_broker_credentials` 쿼리에 daemon_id 컬럼이 없으면 row에 포함되지 않아 항상 6컬럼 → daemon_id 항상 None. 이는 Step1 이전에는 올바른 동작이나 Step1 이후엔 정보 손실.
- **최종 결정**: `list_broker_credentials` 및 `upsert_broker_credential` 쿼리에 `daemon_id` 컬럼을 포함하고, `_row_to_broker_credential`이 7컬럼 행을 처리. 단 ensure_schema의 broker_credentials CREATE TABLE은 수정하지 않음 (Step2 이후 ensure_schema 갱신은 별도 주석).
### DS-5: upsert_broker_credential ON CONFLICT 처리 (REQ-046 Notes)
- REQ-047 Step1 이후 daemon_id 컬럼 존재하나 PK는 여전히 (user_id, broker) → ON CONFLICT 대상 불변
- REQ-047 Step2 이후 PK가 (daemon_id, broker)로 변경 → ON CONFLICT 절이 달라짐
- 이 batch에서는 Step2 이후 ON CONFLICT 절은 TODO 주석으로 표시하고, 현재는 기존 (user_id, broker) ON CONFLICT 유지 + daemon_id INSERT 컬럼 추가
### DS-6: backfill CLI `get_daemon` 조회 방식 (REQ-048)
- `--daemon-id <value>`: daemon_slug로 먼저 시도 (`get_daemon_by_slug(owner_user_id, value)`), 실패 시 `get_daemon_by_id(value)` 재시도
- `--user <username>`으로 user_id를 먼저 조회한 후 get_daemon_by_slug 호출
- 단, backfill은 user-scoped 인증정보를 특정 daemon에 할당하는 작업이므로 `--user`와 `--daemon-id`를 함께 받음
## 기존 테스트 영향 분석
| 테스트 | 영향 | 조치 |
| --- | --- | --- |
| `test_v1.py::test_v1_user_info_returns_user` | `body["brokers"] == []` 참조 | `body["broker"]` is None 으로 변경 |
| `test_v1.py::test_v1_user_info_returns_decrypted_broker_credentials` | `body["brokers"]` 리스트 참조 | `body["broker"]` 단일 필드 검증으로 변경 |
| `test_v1.py::test_v1_user_info_degrades_when_broker_credentials_lookup_fails` | `body["brokers"] == []` 참조 | `body["broker"]` is None 으로 변경 |
| `test_cli.py::FakeRepo.upsert_broker_credential` | daemon_id 파라미터 없음 | 시그니처에 daemon_id 추가 |
| `test_cli.py::FakeRepo.list_broker_credentials` | daemon_id 필터 없음 | daemon_id 필터 추가 |
| `test_cli.py::FakeRepo.delete_broker_credential` | daemon_id 파라미터 없음 | 시그니처에 daemon_id 추가 |
@@ -0,0 +1,39 @@
# BAT-020 — daemon-scoped 브로커 인증정보
- Batch ID: BAT-020
- Profile: standard
- Status: in-progress
- Discovery: dcy-010
- Included REQ: REQ-046, REQ-047, REQ-048, REQ-049, REQ-050, REQ-051
- Folder: docs/batches/bat-020_20260628_daemon-scoped-broker-credential/
- Created: 2026-06-28
## Summary
dcy-010(daemon-scoped 브로커 인증정보 소유·전달)을 portfolio-hub에 구현한다.
BrokerCredential 도메인 모델에 daemon_id 필드 추가, broker_credentials 테이블 PK 전환 마이그레이션(Step1/Step2), 백필 CLI, GET /v1/user_info daemon-scoped 단일 broker 전달, CLI --daemon-id 옵션, 보안 격리 검증.
## Included REQ
| REQ | 제목 | Type | 의존 |
| --- | --- | --- | --- |
| REQ-046 | BrokerCredential daemon_id 필드 추가 | Data | 없음 |
| REQ-047 | broker_credentials PK 전환 마이그레이션 | Migration | REQ-046 |
| REQ-048 | 기존 user-scoped 인증정보 daemon_id 백필 | Migration | REQ-047 Step1 |
| REQ-049 | GET /v1/user_info daemon-scoped 단일 broker 전달 | Interface | REQ-046, REQ-047 |
| REQ-050 | credential CLI --daemon-id 옵션 추가 | Interface | REQ-046, REQ-047 |
| REQ-051 | 브로커 인증정보 보안 격리 | Non-Functional | REQ-049 |
## DoR (진입 조건)
- [x] 6개 REQ 전건 Approved 상태 확인
- [x] dcy-010 confirmed Discovery 확인
- [x] 구현 대상 파일 존재 확인(app/domain/models.py, app/repositories/postgres.py, app/api/v1_user_info.py, app/schemas/v1.py, app/cli/commands/credential.py, migrations/versions/)
- [x] design 위임 2건 식별 완료(REQ-048 구현방식, REQ-050 --daemon-id 입력 타입·과도기 정책)
## DoD (완료 조건)
- [ ] REQ-046~051 전 AC 테스트 통과
- [ ] alembic revision Step1/Step2 작성 완료(dev DB 검증)
- [ ] 기존 테스트 회귀 없음
- [ ] draft-batch-verification 문서에 evidence 기록
## 비가역 결재 게이트
REQ-047 Step2(PK 전환) 실DB 적용은 사장님 결재 대상. 이 batch에서는 revision 코드 작성 + dev/test DB 검증까지만 수행.
@@ -0,0 +1,73 @@
# BAT-020 Planning
## 구현 의존 순서
1. **REQ-046** — BrokerCredential 도메인 모델 + repository 시그니처 변경 (daemon_id 필드)
2. **REQ-047 Step1** — alembic revision: broker_credentials에 daemon_id TEXT NULL 컬럼 추가
3. **REQ-048** — backfill-daemon-id CLI subcommand 추가
4. **REQ-047 Step2** — alembic revision: PK (user_id,broker) → (daemon_id,broker) 전환 (dev/test DB만)
5. **REQ-049** — GET /v1/user_info daemon-scoped 단일 broker 전달 (스키마 + 엔드포인트)
6. **REQ-050** — credential CLI --daemon-id 옵션 추가 (set/list/remove)
7. **REQ-051** — 보안 격리 테스트: service-token 전용, web 토큰 차단, 로그 비노출 검증
## Task Breakdown
### T-1: REQ-046 모델·repository 변경
- `app/domain/models.py` — `BrokerCredential.daemon_id: str | None = None` 추가
- `app/repositories/postgres.py` — `_row_to_broker_credential` daemon_id 처리 (7컬럼/6컬럼 하위호환)
- `app/repositories/postgres.py` — `upsert_broker_credential(daemon_id=None)` 파라미터 추가
- `app/repositories/postgres.py` — `list_broker_credentials(daemon_id=None)` 파라미터 + 필터 추가
- `app/repositories/postgres.py` — `delete_broker_credential(daemon_id=None)` 파라미터 추가
- Tests: REQ-046 AC-1~6 단위 테스트 신규
### T-2: REQ-047 Step1 alembic revision
- `migrations/versions/20260628_0026_broker_credential_daemon_id_step1.py`
- 내용: `broker_credentials`에 `daemon_id TEXT NULL` 컬럼 추가 + downgrade(컬럼 제거, PK 복원)
- Tests: AC-1 (컬럼 존재), AC-2 (기존 데이터 NULL 보존), AC-6 (downgrade)
### T-3: REQ-048 backfill CLI
- `app/cli/commands/credential.py` — `backfill-daemon-id` subcommand 추가
- Tests: AC-1~6 단위 테스트 (mock repo 기반)
### T-4: REQ-047 Step2 alembic revision
- `migrations/versions/20260628_0027_broker_credential_daemon_id_step2.py`
- 내용: PK 전환 (user_id,broker) → (daemon_id,broker), daemon_id NOT NULL 제약, downgrade 안전장치
- `app/repositories/postgres.py` — `ensure_schema()` broker_credentials 정의 최종 스키마로 갱신 (Step2 이후 활성화 주석)
- Tests: AC-3~5 (dev DB에서 Step1→백필→Step2 순서 검증), AC-7 (downgrade 안전장치)
### T-5: REQ-049 user_info daemon-scoped 확장
- `app/schemas/v1.py` — `UserInfoResponse.broker: BrokerItem | None = None`, `brokers: list` 제거
- `app/api/v1_user_info.py` — daemon_id 없으면 403, daemon_id 기반 list_broker_credentials 호출, 단일 broker 반환
- Tests: REQ-049 AC-1~9 (daemon_id 있음/없음, broker 있음/없음, 2데몬 격리, 복호화 실패, 회귀)
### T-6: REQ-050 credential CLI --daemon-id
- `app/cli/commands/credential.py` — set/list/remove 에 `--daemon-id` 옵션 추가
- `tests/test_cli.py` FakeRepo — upsert/list/delete에 daemon_id 파라미터 반영
- Tests: REQ-050 AC-1~7
### T-7: REQ-051 보안 격리 테스트
- web 토큰 403 (AC-1), 비인증 401 (AC-2), daemon_id=None 403 (AC-3), 타데몬 null (AC-4)
- 타 엔드포인트 credential 미노출 (AC-5)
- DB 오류 graceful degrade (AC-6), 복호화 실패 degrade (AC-7)
- 로그 비노출 (AC-8, caplog fixture)
## 파일 변경 대상 요약
| 파일 | 변경 내용 |
| --- | --- |
| `app/domain/models.py` | BrokerCredential.daemon_id 추가 |
| `app/repositories/postgres.py` | _row_to_broker_credential, upsert/list/delete 시그니처, ensure_schema broker_credentials |
| `app/schemas/v1.py` | UserInfoResponse brokers→broker 단일화 |
| `app/api/v1_user_info.py` | daemon-scoped 조회, daemon_id 없음 403, 로그 비노출 확인 |
| `app/cli/commands/credential.py` | set/list/remove --daemon-id, backfill-daemon-id subcommand |
| `migrations/versions/20260628_0026_*.py` | Step1 revision |
| `migrations/versions/20260628_0027_*.py` | Step2 revision |
| `tests/test_v1.py` | user_info 테스트 갱신 + 신규(daemon-scoped, 보안) |
| `tests/test_cli.py` | credential CLI 테스트 갱신 + 신규 |
| `tests/test_bat020_broker_credential_domain.py` | REQ-046 단위 테스트 신규 |
| `tests/test_bat020_broker_credential_migration.py` | REQ-047 마이그레이션 테스트 신규 |
## 위험
- REQ-049: `brokers` → `broker` 응답 변경으로 기존 `test_v1.py`의 `brokers` 필드 참조 테스트가 실패할 수 있음 → 갱신 필수
- REQ-047 Step2: 실 DB 적용 금지(게이트). dev/test DB에서만 검증.
- `get_daemon_by_slug`은 `owner_user_id` + `daemon_slug` 조합으로 조회 → REQ-050에서 daemon_slug로 받으면 user 조회 먼저 필요
@@ -0,0 +1,69 @@
"""broker_credentials: daemon_id TEXT NULL 컬럼 추가 (REQ-047 Step1)
Revision ID: 20260628_0026
Revises: 20260605_0025
Create Date: 2026-06-28 00:00:00
NOTE: 이 revision은 REQ-047 Step1 — nullable daemon_id 컬럼 추가.
기존 데이터는 daemon_id=NULL 상태로 보존된다(백필은 REQ-048 CLI 별도 수행).
Step2(PK 전환)는 20260628_0027_broker_credential_daemon_id_step2.py에서 수행.
Step2는 비가역(PK 변경)이므로 사장님 결재 후 별도 적용.
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260628_0026"
down_revision = "20260605_0025"
branch_labels = None
depends_on = None
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
if not _table_exists(inspector, table_name):
return False
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
# broker_credentials에 daemon_id TEXT NULL 컬럼 추가
# 기존 PK (user_id, broker)는 그대로 유지
if _table_exists(inspector, "broker_credentials"):
if not _column_exists(inspector, "broker_credentials", "daemon_id"):
op.add_column(
"broker_credentials",
sa.Column("daemon_id", sa.Text(), nullable=True),
)
# daemon_id 인덱스 추가 (조회 성능)
op.create_index(
"idx_broker_credentials_daemon_id",
"broker_credentials",
["daemon_id"],
unique=False,
postgresql_where=sa.text("daemon_id IS NOT NULL"),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _table_exists(inspector, "broker_credentials"):
# 인덱스 먼저 제거
existing_indexes = [idx["name"] for idx in inspector.get_indexes("broker_credentials")]
if "idx_broker_credentials_daemon_id" in existing_indexes:
op.drop_index("idx_broker_credentials_daemon_id", table_name="broker_credentials")
if _column_exists(inspector, "broker_credentials", "daemon_id"):
op.drop_column("broker_credentials", "daemon_id")
@@ -0,0 +1,144 @@
"""broker_credentials: PK (user_id,broker) → (daemon_id,broker) 전환 (REQ-047 Step2)
Revision ID: 20260628_0027
Revises: 20260628_0026
Create Date: 2026-06-28 00:00:00
CRITICAL: 이 revision은 비가역 PK 변경을 포함한다.
- 실 운영 DB 적용 전 사장님 결재 필수 (dcy-010 R-1, 외부 비가역 작업 게이트)
- REQ-048 backfill-daemon-id CLI로 모든 행의 daemon_id가 채워진 이후에만 적용 가능
(NOT NULL 제약으로 인해 daemon_id=NULL 행 존재 시 upgrade 실패)
- downgrade는 데이터 손실 위험 — 운영 환경에서 rollback 전 별도 데이터 검증 필요
적용 순서:
1. 20260628_0026 (Step1) — daemon_id NULL 컬럼 추가
2. pfhub credential backfill-daemon-id (REQ-048 CLI)
3. 20260628_0027 (Step2, 이 파일) — PK 전환 (사장님 결재 후 적용)
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect, text
revision = "20260628_0027"
down_revision = "20260628_0026"
branch_labels = None
depends_on = None
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
if not _table_exists(inspector, table_name):
return False
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
def _constraint_exists(bind, table_name: str, constraint_name: str) -> bool:
result = bind.execute(text(
"""
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = :table AND constraint_name = :cname
"""
), {"table": table_name, "cname": constraint_name})
return result.fetchone() is not None
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not _table_exists(inspector, "broker_credentials"):
return
# 사전 검증: daemon_id IS NULL인 행이 있으면 upgrade 거부
result = bind.execute(text(
"SELECT COUNT(*) FROM broker_credentials WHERE daemon_id IS NULL"
))
null_count = result.scalar()
if null_count and null_count > 0:
raise RuntimeError(
f"broker_credentials에 daemon_id IS NULL인 행이 {null_count}건 있습니다. "
"REQ-048 backfill-daemon-id CLI로 모든 행을 백필한 후 다시 실행하세요."
)
# Step 1: daemon_id NOT NULL 제약 추가
if _column_exists(inspector, "broker_credentials", "daemon_id"):
op.alter_column("broker_credentials", "daemon_id", nullable=False)
# Step 2: 기존 PK (user_id, broker) 제거
# PostgreSQL: PK 이름 찾기
pk_constraints = [
c for c in inspector.get_pk_constraint("broker_credentials").get("constrained_columns", [])
]
# PK 제약 이름을 직접 조회
bind.execute(text(
"""
ALTER TABLE broker_credentials DROP CONSTRAINT IF EXISTS broker_credentials_pkey
"""
))
# Step 3: 새 PK (daemon_id, broker) 추가
op.create_primary_key(
"broker_credentials_pkey",
"broker_credentials",
["daemon_id", "broker"],
)
# NOTE: user_id 컬럼은 감사/이력 목적으로 유지 (삭제 금지, REQ-047 AC-4)
def downgrade() -> None:
"""
WARN: 이 downgrade는 데이터 손실 위험을 수반한다.
(daemon_id, broker) PK를 (user_id, broker)로 복원할 때,
같은 user_id + broker 조합이 여러 daemon에 존재하면 충돌이 발생한다.
운영 환경에서는 downgrade 전 반드시 별도 데이터 검증을 수행하라.
"""
bind = op.get_bind()
inspector = inspect(bind)
if not _table_exists(inspector, "broker_credentials"):
return
# 안전장치: 실 운영 환경에서 downgrade 시도 경고
# (REQ-047 AC-7: downgrade 실패 또는 명시적 경고 존재)
result = bind.execute(text(
"""
SELECT COUNT(*) FROM (
SELECT user_id, broker, COUNT(*) as cnt
FROM broker_credentials
GROUP BY user_id, broker
HAVING COUNT(*) > 1
) t
"""
))
conflict_count = result.scalar()
if conflict_count and conflict_count > 0:
raise RuntimeError(
f"downgrade 불가: {conflict_count}개 (user_id, broker) 조합이 중복됩니다. "
"같은 user의 여러 daemon이 동일 broker를 사용하고 있어 "
"(user_id, broker) PK로 복원 시 데이터 손실이 발생합니다. "
"수동으로 데이터를 정리한 후 downgrade하세요."
)
# PK 전환 역방향: (daemon_id, broker) → (user_id, broker)
bind.execute(text(
"ALTER TABLE broker_credentials DROP CONSTRAINT IF EXISTS broker_credentials_pkey"
))
# daemon_id 컬럼을 nullable로 복원
if _column_exists(inspector, "broker_credentials", "daemon_id"):
op.alter_column("broker_credentials", "daemon_id", nullable=True)
# (user_id, broker) PK 복원
op.create_primary_key(
"broker_credentials_pkey",
"broker_credentials",
["user_id", "broker"],
)
@@ -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
+460
View File
@@ -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
View File
@@ -175,6 +175,7 @@ class FakeRepo:
*, *,
account_ref: str, account_ref: str,
encrypted_payload: str, encrypted_payload: str,
daemon_id: str | None = None,
) -> BrokerCredential: ) -> BrokerCredential:
self.get_user(user_id) self.get_user(user_id)
key = (user_id, broker) key = (user_id, broker)
@@ -186,17 +187,65 @@ class FakeRepo:
encrypted_payload=encrypted_payload, encrypted_payload=encrypted_payload,
credential_version=(existing.credential_version + 1) if existing is not None else 1, credential_version=(existing.credential_version + 1) if existing is not None else 1,
last_rotated_at=datetime.now(timezone.utc), last_rotated_at=datetime.now(timezone.utc),
daemon_id=daemon_id,
) )
self._broker_credentials[key] = credential self._broker_credentials[key] = credential
return credential return credential
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]: def list_broker_credentials(
return sorted( self,
[credential for (owner_user_id, _), credential in self._broker_credentials.items() if owner_user_id == user_id], user_id: str,
key=lambda credential: credential.broker, 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) key = (user_id, broker)
if key not in self._broker_credentials: if key not in self._broker_credentials:
return False 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: def test_cli_credential_set_list_and_remove_flow() -> None:
"""REQ-050: --daemon-id 포함 credential set/list/remove 플로우 (BAT-020 갱신)."""
repo = FakeRepo(_portfolio()) repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read") user = repo.create_user("alice", permission="read")
daemon = repo.create_daemon(user.id, daemon_slug="orb-bot")
runner = build_runner(repo) runner = build_runner(repo)
set_result = runner.invoke( set_result = runner.invoke(
@@ -999,6 +1050,8 @@ def test_cli_credential_set_list_and_remove_flow() -> None:
"set", "set",
"--user", "--user",
"alice", "alice",
"--daemon-id",
"orb-bot",
"--broker", "--broker",
"kis", "kis",
"--account-ref", "--account-ref",
@@ -1023,18 +1076,16 @@ def test_cli_credential_set_list_and_remove_flow() -> None:
set_payload = json.loads(set_result.stdout) set_payload = json.loads(set_result.stdout)
assert set_payload["user_id"] == user.id assert set_payload["user_id"] == user.id
assert set_payload["broker"] == "kis" assert set_payload["broker"] == "kis"
assert set_payload["daemon_id"] == daemon.id
assert set_payload["credential_version"] == 1 assert set_payload["credential_version"] == 1
assert list_result.exit_code == 0 assert list_result.exit_code == 0
list_payload = json.loads(list_result.stdout) list_payload = json.loads(list_result.stdout)
assert list_payload == [ assert len(list_payload) == 1
{ assert list_payload[0]["broker"] == "kis"
"broker": "kis", assert list_payload[0]["account_ref"] == "123-45"
"account_ref": "123-45", assert list_payload[0]["daemon_id"] == daemon.id
"credential_version": 1, assert list_payload[0]["credential_version"] == 1
"last_rotated_at": list_payload[0]["last_rotated_at"],
}
]
assert remove_result.exit_code == 0 assert remove_result.exit_code == 0
remove_payload = json.loads(remove_result.stdout) remove_payload = json.loads(remove_result.stdout)
+4 -3
View File
@@ -120,7 +120,8 @@ def test_user_info_returns_daemon_id_in_response(monkeypatch) -> None:
assert resp.json()["daemon_id"] == "d-1" 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) client = _app_with_db(monkeypatch)
principal = _principal(daemon_id=None) principal = _principal(daemon_id=None)
user = User(id="user-1", username="alice", permission="read", is_active=True) 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): patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
resp = client.get("/v1/user_info", headers=_svc_header()) resp = client.get("/v1/user_info", headers=_svc_header())
assert resp.status_code == 200 # REQ-049: daemon_id 없는 service token은 user_info 403 (daemon-scoped 인가)
assert resp.json()["daemon_id"] is None assert resp.status_code == 403
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+63 -24
View File
@@ -118,7 +118,7 @@ class _RuntimeIdRoundTripRepo:
daemon_id=token.daemon_id, 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 [] return []
@@ -144,6 +144,9 @@ def test_health_returns_full_response() -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_auth_issue_service_token_returns_runtime_id(monkeypatch) -> 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() repo = _RuntimeIdRoundTripRepo()
settings = _make_settings( settings = _make_settings(
db_enabled=True, 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 issue_body["runtime_id"] is not None
assert "token" in issue_body assert "token" in issue_body
# REQ-049 AC-3: daemon_id 없는 service token → user_info 403
user_info_resp = client.get( user_info_resp = client.get(
"/v1/user_info", "/v1/user_info",
headers=_service_token_header(issue_body["token"]), headers=_service_token_header(issue_body["token"]),
) )
assert user_info_resp.status_code == 200 assert user_info_resp.status_code == 403
user_info_body = user_info_resp.json()
assert user_info_body["runtime_id"] == issue_body["runtime_id"]
assert user_info_body["username"] == "alice"
def test_auth_issue_service_token_generates_distinct_runtime_ids_per_token(monkeypatch) -> None: 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: 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) settings = _make_app_db(monkeypatch)
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read", 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) 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: def test_v1_user_info_returns_user(monkeypatch) -> None:
# REQ-049: daemon_id 있음 + broker=None → 200 반환
settings = _make_app_db(monkeypatch) settings = _make_app_db(monkeypatch)
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read", 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( user = User(
id="user-1", username="alice", email="alice@example.com", 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["username"] == "alice"
assert body["trading_enabled"] is True assert body["trading_enabled"] is True
assert body["disabled_reason"] is None 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: def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None:
# daemon_id 있음 + 비활성 user → disabled_reason
settings = _make_app_db(monkeypatch) settings = _make_app_db(monkeypatch)
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="user-2", permission="read", 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( user = User(
id="user-2", username="bob", email=None, 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" assert body["disabled_reason"] == "account_inactive"
def test_v1_user_info_returns_decrypted_broker_credentials(monkeypatch) -> None: def test_v1_user_info_returns_decrypted_broker_credential(monkeypatch) -> None:
"""REQ-007 AC5: brokers[]에 복호화된 credential_payload 반환.""" """REQ-049 AC-1: daemon_id 있음 + broker 인증정보 있음 → 단일 broker 반환."""
from app.core.encryption import encrypt_payload from app.core.encryption import encrypt_payload
import json as _json import json as _json
@@ -307,12 +334,13 @@ def test_v1_user_info_returns_decrypted_broker_credentials(monkeypatch) -> None:
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read", 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) user = User(id="user-1", username="alice", permission="read", is_active=True)
cred = BrokerCredential( cred = BrokerCredential(
user_id="user-1", broker="kis", account_ref="12345678", user_id="user-1", broker="kis", account_ref="12345678",
encrypted_payload=encrypted, credential_version=1, encrypted_payload=encrypted, credential_version=1,
daemon_id="daemon-1",
) )
mock_repo = MagicMock() mock_repo = MagicMock()
@@ -331,8 +359,11 @@ def test_v1_user_info_returns_decrypted_broker_credentials(monkeypatch) -> None:
assert resp.status_code == 200 assert resp.status_code == 200
body = resp.json() body = resp.json()
assert len(body["brokers"]) == 1 # REQ-049 AC-6: broker 단일 필드, brokers 리스트 없음
broker = body["brokers"][0] assert "broker" in body
assert "brokers" not in body
broker = body["broker"]
assert broker is not None
assert broker["broker"] == "kis" assert broker["broker"] == "kis"
assert broker["credential_payload"] == {"app_key": "mykey", "app_secret": "mysecret"} 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 # 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") monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read", 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) 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 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") monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read", 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( user = User(
id="user-1", username="alice", email="alice@example.com", 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 = MagicMock()
mock_repo.get_token_principal.return_value = principal mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user 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), \ 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): 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["runtime_id"] == "rt-abc"
assert body["username"] == "alice" assert body["username"] == "alice"
assert body["disabled_reason"] is None 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: def test_v1_user_info_degrades_when_broker_credentials_lookup_fails(monkeypatch) -> None:
"""issue #9 regression: broker_credentials lookup failure (missing table / DB error) """REQ-049 AC-2, REQ-051 AC-6: broker_credentials lookup failure → broker=null graceful degrade."""
must NOT crash /v1/user_info with 500. daemon_id/user_id/permission do not depend on
broker credentials — degrade gracefully with empty brokers."""
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true") monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
principal = TokenPrincipal( principal = TokenPrincipal(
@@ -872,15 +907,18 @@ def test_v1_user_info_degrades_when_broker_credentials_lookup_fails(monkeypatch)
body = resp.json() body = resp.json()
assert body["daemon_id"] == "daemon-9" assert body["daemon_id"] == "daemon-9"
assert body["user_id"] == "user-1" 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") monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="user-2", permission="read", 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( user = User(
id="user-2", username="bob", email=None, 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 = MagicMock()
mock_repo.get_token_principal.return_value = principal mock_repo.get_token_principal.return_value = principal
mock_repo.get_user.return_value = user 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), \ 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): patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):