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:
+34
-16
@@ -31,41 +31,59 @@ def get_user_info(
|
||||
auth: AuthContext = Depends(_require_service_token),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> 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)
|
||||
try:
|
||||
user = repo.get_user(auth.user_id) # type: ignore[arg-type]
|
||||
|
||||
# Fetch broker credentials and decrypt
|
||||
brokers: list[BrokerItem] = []
|
||||
# REQ-049: daemon_id로 단일 broker 인증정보 조회 및 복호화
|
||||
broker_item: BrokerItem | None = None
|
||||
try:
|
||||
credentials = repo.list_broker_credentials(auth.user_id) # type: ignore[arg-type]
|
||||
enc_key: str | None = None
|
||||
for cred in credentials:
|
||||
credentials = repo.list_broker_credentials( # type: ignore[call-arg]
|
||||
auth.user_id, # type: ignore[arg-type]
|
||||
daemon_id=auth.daemon_id,
|
||||
)
|
||||
if credentials:
|
||||
cred = credentials[0]
|
||||
credential_payload = None
|
||||
try:
|
||||
if enc_key is None:
|
||||
enc_key = settings.require_credential_encryption_key()
|
||||
raw = decrypt_payload(enc_key, cred.encrypted_payload)
|
||||
credential_payload = json.loads(raw)
|
||||
except (InvalidToken, RuntimeError, Exception) as exc:
|
||||
logger.warning("credential decryption failed for broker %s: %s", cred.broker, exc)
|
||||
brokers.append(BrokerItem(
|
||||
# REQ-051 AC-8: 로그에 app_key, app_secret 값 미포함
|
||||
# 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,
|
||||
account_ref=cred.account_ref,
|
||||
credential_payload=credential_payload,
|
||||
credential_version=cred.credential_version,
|
||||
last_rotated_at=cred.last_rotated_at,
|
||||
))
|
||||
)
|
||||
except AttributeError:
|
||||
# repo does not implement list_broker_credentials yet (mock or older impl)
|
||||
pass
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
# Broker credentials are supplementary. A failure here (e.g. missing
|
||||
# broker_credentials table, DB error) must not crash /v1/user_info —
|
||||
# the critical fields (daemon_id, user_id, permission) do not depend
|
||||
# on broker credentials. Degrade gracefully with an empty broker list.
|
||||
logger.warning("broker credential lookup failed; returning empty brokers: %s", exc)
|
||||
brokers = []
|
||||
# REQ-049, REQ-051: broker 인증정보 조회 실패는 broker=null로 graceful degrade.
|
||||
# daemon_id, trading_enabled 필수 필드는 영향 없음.
|
||||
logger.warning(
|
||||
"broker credential lookup failed for daemon %s; returning broker=null: %s",
|
||||
auth.daemon_id,
|
||||
type(exc).__name__,
|
||||
)
|
||||
broker_item = None
|
||||
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
@@ -80,7 +98,7 @@ def get_user_info(
|
||||
daemon_id=auth.daemon_id,
|
||||
trading_enabled=user.is_active,
|
||||
disabled_reason=disabled_reason,
|
||||
brokers=brokers,
|
||||
broker=broker_item,
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
@@ -28,10 +29,25 @@ def _with_service(ctx: typer.Context, callback):
|
||||
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")
|
||||
def set_credential(
|
||||
ctx: typer.Context,
|
||||
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)."),
|
||||
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."),
|
||||
@@ -45,6 +61,16 @@ def set_credential(
|
||||
owner = repository.get_user_by_username(user)
|
||||
if owner is None:
|
||||
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()
|
||||
payload = json.dumps({"app_key": app_key, "app_secret": app_secret})
|
||||
encrypted = encrypt_payload(enc_key, payload)
|
||||
@@ -53,6 +79,7 @@ def set_credential(
|
||||
broker,
|
||||
account_ref=account_ref,
|
||||
encrypted_payload=encrypted,
|
||||
daemon_id=resolved_daemon_id,
|
||||
)
|
||||
|
||||
result = _with_service(ctx, _callback)
|
||||
@@ -62,6 +89,7 @@ def set_credential(
|
||||
ctx.obj.output_format,
|
||||
{
|
||||
"user_id": result.user_id,
|
||||
"daemon_id": result.daemon_id,
|
||||
"broker": result.broker,
|
||||
"account_ref": result.account_ref,
|
||||
"credential_version": result.credential_version,
|
||||
@@ -74,6 +102,7 @@ def set_credential(
|
||||
def list_credentials(
|
||||
ctx: typer.Context,
|
||||
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:
|
||||
"""List broker credentials for a user (payload not shown)."""
|
||||
|
||||
@@ -81,9 +110,15 @@ def list_credentials(
|
||||
owner = repository.get_user_by_username(user)
|
||||
if owner is None:
|
||||
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 [
|
||||
{
|
||||
"daemon_id": r.daemon_id,
|
||||
"broker": r.broker,
|
||||
"account_ref": r.account_ref,
|
||||
"credential_version": r.credential_version,
|
||||
@@ -107,6 +142,7 @@ def list_credentials(
|
||||
def remove_credential(
|
||||
ctx: typer.Context,
|
||||
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)."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
@@ -117,7 +153,12 @@ def remove_credential(
|
||||
owner = repository.get_user_by_username(user)
|
||||
if owner is None:
|
||||
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:
|
||||
raise ValueError(f"인증 정보를 찾을 수 없습니다: user={user} broker={broker}")
|
||||
return {"deleted": True, "user": user, "broker": broker}
|
||||
@@ -125,3 +166,71 @@ def remove_credential(
|
||||
result = _with_service(ctx, _callback)
|
||||
if result is not None:
|
||||
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)
|
||||
|
||||
@@ -182,4 +182,5 @@ class BrokerCredential:
|
||||
encrypted_payload: str
|
||||
credential_version: int = 1
|
||||
last_rotated_at: datetime | None = None
|
||||
daemon_id: str | None = None
|
||||
|
||||
|
||||
@@ -474,6 +474,7 @@ class PostgresPortfolioRepository:
|
||||
encrypted_payload=row[3],
|
||||
credential_version=int(row[4]) if len(row) > 4 else 1,
|
||||
last_rotated_at=row[5] if len(row) > 5 else None,
|
||||
daemon_id=row[6] if len(row) > 6 else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -739,12 +740,34 @@ class PostgresPortfolioRepository:
|
||||
*,
|
||||
account_ref: str,
|
||||
encrypted_payload: str,
|
||||
daemon_id: str | None = None,
|
||||
) -> BrokerCredential:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,))
|
||||
if cur.fetchone() is None:
|
||||
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(
|
||||
"""
|
||||
INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload)
|
||||
@@ -761,9 +784,39 @@ class PostgresPortfolioRepository:
|
||||
row = cur.fetchone()
|
||||
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()
|
||||
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(
|
||||
"""
|
||||
SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
|
||||
@@ -776,9 +829,41 @@ class PostgresPortfolioRepository:
|
||||
rows = cur.fetchall()
|
||||
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()
|
||||
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(
|
||||
"DELETE FROM broker_credentials WHERE user_id = %s AND broker = %s RETURNING user_id",
|
||||
(user_id, broker),
|
||||
|
||||
+1
-1
@@ -96,7 +96,7 @@ class UserInfoResponse(BaseModel):
|
||||
daemon_id: str | None = None
|
||||
trading_enabled: bool
|
||||
disabled_reason: str | None = None
|
||||
brokers: list[BrokerItem] = []
|
||||
broker: BrokerItem | None = None
|
||||
# Additional informational fields
|
||||
user_id: 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
|
||||
@@ -0,0 +1,460 @@
|
||||
"""BAT-020: REQ-050, REQ-048 — credential CLI --daemon-id + backfill 테스트."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from app.cli.main import create_app
|
||||
from app.core.config import Settings
|
||||
from app.core.encryption import encrypt_payload
|
||||
from app.core.security import hash_token, token_prefix
|
||||
from app.domain.models import AuthToken, BrokerCredential, Daemon, User
|
||||
|
||||
|
||||
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal FakeRepo for credential CLI tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CredFakeRepo:
|
||||
"""credential CLI 테스트용 최소 FakeRepo."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._users: dict[str, User] = {}
|
||||
self._daemons: dict[str, Daemon] = {}
|
||||
self._broker_credentials: dict[tuple, BrokerCredential] = {}
|
||||
self._seq = 1
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def ping(self) -> None:
|
||||
pass
|
||||
|
||||
def ensure_schema(self) -> None:
|
||||
pass
|
||||
|
||||
def get_user(self, user_id: str) -> User:
|
||||
u = self._users.get(user_id)
|
||||
if u is None:
|
||||
raise ValueError("사용자를 찾을 수 없습니다")
|
||||
return u
|
||||
|
||||
def get_user_by_username(self, username: str) -> User | None:
|
||||
return next((u for u in self._users.values() if u.username == username), None)
|
||||
|
||||
def create_user(self, username: str, **kwargs) -> User:
|
||||
user = User(
|
||||
id=f"user-{self._seq}",
|
||||
username=username,
|
||||
email=kwargs.get("email"),
|
||||
permission=kwargs.get("permission", "read"),
|
||||
is_active=kwargs.get("is_active", True),
|
||||
)
|
||||
self._seq += 1
|
||||
self._users[user.id] = user
|
||||
return user
|
||||
|
||||
def create_daemon(self, owner_user_id: str, *, daemon_slug: str, display_name: str | None = None) -> Daemon:
|
||||
d = Daemon(
|
||||
id=f"daemon-{self._seq}",
|
||||
owner_user_id=owner_user_id,
|
||||
daemon_slug=daemon_slug,
|
||||
display_name=display_name,
|
||||
is_active=True,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
self._seq += 1
|
||||
self._daemons[d.id] = d
|
||||
return d
|
||||
|
||||
def get_daemon_by_slug(self, owner_user_id: str, daemon_slug: str) -> Daemon | None:
|
||||
return next(
|
||||
(d for d in self._daemons.values() if d.owner_user_id == owner_user_id and d.daemon_slug == daemon_slug),
|
||||
None,
|
||||
)
|
||||
|
||||
def get_daemon_by_id(self, daemon_id: str) -> Daemon | None:
|
||||
return self._daemons.get(daemon_id)
|
||||
|
||||
def upsert_broker_credential(
|
||||
self,
|
||||
user_id: str,
|
||||
broker: str,
|
||||
*,
|
||||
account_ref: str,
|
||||
encrypted_payload: str,
|
||||
daemon_id: str | None = None,
|
||||
) -> BrokerCredential:
|
||||
self.get_user(user_id)
|
||||
key = (user_id, broker)
|
||||
existing = self._broker_credentials.get(key)
|
||||
cred = BrokerCredential(
|
||||
user_id=user_id,
|
||||
broker=broker,
|
||||
account_ref=account_ref,
|
||||
encrypted_payload=encrypted_payload,
|
||||
credential_version=(existing.credential_version + 1) if existing else 1,
|
||||
last_rotated_at=datetime.now(timezone.utc),
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
self._broker_credentials[key] = cred
|
||||
return cred
|
||||
|
||||
def list_broker_credentials(
|
||||
self,
|
||||
user_id: str,
|
||||
daemon_id: str | None = None,
|
||||
) -> list[BrokerCredential]:
|
||||
creds = [c for (uid, _), c in self._broker_credentials.items() if uid == user_id]
|
||||
if daemon_id is not None:
|
||||
creds = [c for c in creds if c.daemon_id == daemon_id]
|
||||
return sorted(creds, key=lambda c: c.broker)
|
||||
|
||||
def update_broker_credential_daemon_id(self, user_id: str, broker: str, daemon_id: str) -> bool:
|
||||
key = (user_id, broker)
|
||||
cred = self._broker_credentials.get(key)
|
||||
if cred is None or cred.daemon_id is not None:
|
||||
return False
|
||||
self._broker_credentials[key] = BrokerCredential(
|
||||
user_id=cred.user_id, broker=cred.broker, account_ref=cred.account_ref,
|
||||
encrypted_payload=cred.encrypted_payload, credential_version=cred.credential_version,
|
||||
last_rotated_at=cred.last_rotated_at, daemon_id=daemon_id,
|
||||
)
|
||||
return True
|
||||
|
||||
def delete_broker_credential(self, user_id: str, broker: str, daemon_id: str | None = None) -> bool:
|
||||
if daemon_id is not None:
|
||||
for key, cred in list(self._broker_credentials.items()):
|
||||
if cred.daemon_id == daemon_id and cred.broker == broker:
|
||||
del self._broker_credentials[key]
|
||||
return True
|
||||
return False
|
||||
key = (user_id, broker)
|
||||
if key not in self._broker_credentials:
|
||||
return False
|
||||
del self._broker_credentials[key]
|
||||
return True
|
||||
|
||||
|
||||
def _settings_factory() -> Settings:
|
||||
return Settings(
|
||||
allowed_origins_raw="http://localhost:3000",
|
||||
credential_encryption_key=_TEST_ENC_KEY,
|
||||
)
|
||||
|
||||
|
||||
def _build_runner(repo: CredFakeRepo) -> Any:
|
||||
app = create_app(settings_factory=_settings_factory, repository_factory=lambda settings: repo)
|
||||
runner = CliRunner()
|
||||
runner.app = app
|
||||
return runner
|
||||
|
||||
|
||||
def _make_encrypted(app_key: str = "k", app_secret: str = "s") -> str:
|
||||
return encrypt_payload(_TEST_ENC_KEY, json.dumps({"app_key": app_key, "app_secret": app_secret}))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-050 AC-1: credential set --daemon-id 성공
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_credential_set_with_daemon_id() -> None:
|
||||
"""REQ-050 AC-1: set --daemon-id → broker_credentials에 daemon_id 포함 upsert."""
|
||||
repo = CredFakeRepo()
|
||||
user = repo.create_user("alice")
|
||||
daemon = repo.create_daemon(user.id, daemon_slug="orb-bot")
|
||||
runner = _build_runner(repo)
|
||||
|
||||
result = runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "set",
|
||||
"--user", "alice",
|
||||
"--daemon-id", "orb-bot",
|
||||
"--broker", "kis",
|
||||
"--account-ref", "acc-123",
|
||||
"--app-key", "mykey",
|
||||
"--app-secret", "mysecret",
|
||||
"--force",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["broker"] == "kis"
|
||||
assert payload["daemon_id"] == daemon.id
|
||||
assert payload["account_ref"] == "acc-123"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-050 AC-2: credential list --daemon-id 필터
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_credential_list_daemon_id_filter() -> None:
|
||||
"""REQ-050 AC-2: list --daemon-id → 해당 daemon 인증정보만 출력."""
|
||||
repo = CredFakeRepo()
|
||||
user = repo.create_user("alice")
|
||||
d1 = repo.create_daemon(user.id, daemon_slug="bot-d1")
|
||||
d2 = repo.create_daemon(user.id, daemon_slug="bot-d2")
|
||||
|
||||
enc = _make_encrypted()
|
||||
repo.upsert_broker_credential(user.id, "kis", account_ref="ref-d1", encrypted_payload=enc, daemon_id=d1.id)
|
||||
repo.upsert_broker_credential(user.id, "kis2", account_ref="ref-d2", encrypted_payload=enc, daemon_id=d2.id)
|
||||
|
||||
runner = _build_runner(repo)
|
||||
result = runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "list",
|
||||
"--user", "alice",
|
||||
"--daemon-id", "bot-d1",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
items = json.loads(result.stdout)
|
||||
assert len(items) == 1
|
||||
assert items[0]["daemon_id"] == d1.id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-050 AC-3: credential list 전체 (daemon-id 미지정)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_credential_list_no_daemon_id_returns_all() -> None:
|
||||
"""REQ-050 AC-3: list --daemon-id 미지정 → 전체 인증정보."""
|
||||
repo = CredFakeRepo()
|
||||
user = repo.create_user("alice")
|
||||
d1 = repo.create_daemon(user.id, daemon_slug="bot-d1")
|
||||
d2 = repo.create_daemon(user.id, daemon_slug="bot-d2")
|
||||
|
||||
enc = _make_encrypted()
|
||||
repo.upsert_broker_credential(user.id, "kis", account_ref="ref-d1", encrypted_payload=enc, daemon_id=d1.id)
|
||||
repo.upsert_broker_credential(user.id, "kis2", account_ref="ref-d2", encrypted_payload=enc, daemon_id=d2.id)
|
||||
|
||||
runner = _build_runner(repo)
|
||||
result = runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "list",
|
||||
"--user", "alice",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
items = json.loads(result.stdout)
|
||||
assert len(items) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-050 AC-4: credential remove --daemon-id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_credential_remove_with_daemon_id() -> None:
|
||||
"""REQ-050 AC-4: remove --daemon-id → (daemon_id, broker) 기반 삭제."""
|
||||
repo = CredFakeRepo()
|
||||
user = repo.create_user("alice")
|
||||
d1 = repo.create_daemon(user.id, daemon_slug="orb-bot")
|
||||
|
||||
enc = _make_encrypted()
|
||||
repo.upsert_broker_credential(user.id, "kis", account_ref="ref", encrypted_payload=enc, daemon_id=d1.id)
|
||||
|
||||
runner = _build_runner(repo)
|
||||
result = runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "remove",
|
||||
"--user", "alice",
|
||||
"--daemon-id", "orb-bot",
|
||||
"--broker", "kis",
|
||||
"--force",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
remaining = repo.list_broker_credentials(user.id)
|
||||
assert len(remaining) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-050 AC-5: 존재하지 않는 daemon_slug → exit 1
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_credential_set_nonexistent_daemon_exits_1() -> None:
|
||||
"""REQ-050 AC-5: 존재하지 않는 daemon_slug → 오류 + exit 1."""
|
||||
repo = CredFakeRepo()
|
||||
repo.create_user("alice")
|
||||
|
||||
runner = _build_runner(repo)
|
||||
result = runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "set",
|
||||
"--user", "alice",
|
||||
"--daemon-id", "nonexistent-bot",
|
||||
"--broker", "kis",
|
||||
"--account-ref", "acc",
|
||||
"--app-key", "k",
|
||||
"--app-secret", "s",
|
||||
"--force",
|
||||
])
|
||||
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-050 AC-7: CLI 출력에 daemon_id 포함
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_credential_set_output_includes_daemon_id() -> None:
|
||||
"""REQ-050 AC-7: set 성공 출력에 daemon_id 포함."""
|
||||
repo = CredFakeRepo()
|
||||
user = repo.create_user("alice")
|
||||
daemon = repo.create_daemon(user.id, daemon_slug="tsl-bot")
|
||||
runner = _build_runner(repo)
|
||||
|
||||
result = runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "set",
|
||||
"--user", "alice",
|
||||
"--daemon-id", "tsl-bot",
|
||||
"--broker", "kis",
|
||||
"--account-ref", "acc-999",
|
||||
"--app-key", "k2",
|
||||
"--app-secret", "s2",
|
||||
"--force",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.stdout)
|
||||
assert "daemon_id" in payload
|
||||
assert "broker" in payload
|
||||
assert "account_ref" in payload
|
||||
assert "credential_version" in payload
|
||||
assert "last_rotated_at" in payload
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-048 AC-1/2: backfill-daemon-id 정상 백필
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_backfill_daemon_id_updates_null_rows() -> None:
|
||||
"""REQ-048 AC-1/2: daemon_id IS NULL 인증정보 → 백필 후 daemon_id 세팅."""
|
||||
repo = CredFakeRepo()
|
||||
user = repo.create_user("alice")
|
||||
daemon = repo.create_daemon(user.id, daemon_slug="orb-bot")
|
||||
|
||||
enc = _make_encrypted()
|
||||
# daemon_id=None으로 등록 (기존 user-scoped 방식)
|
||||
repo.upsert_broker_credential(user.id, "kis", account_ref="ref", encrypted_payload=enc, daemon_id=None)
|
||||
|
||||
runner = _build_runner(repo)
|
||||
result = runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "backfill-daemon-id",
|
||||
"--user", "alice",
|
||||
"--daemon-id", "orb-bot",
|
||||
"--force",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# 백필 후 daemon_id 세팅 확인
|
||||
updated = repo.list_broker_credentials(user.id)
|
||||
assert len(updated) == 1
|
||||
assert updated[0].daemon_id == daemon.id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-048 AC-3: --dry-run 시 DB 변경 없음
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_backfill_daemon_id_dry_run_no_change() -> None:
|
||||
"""REQ-048 AC-3: --dry-run → 실제 UPDATE 없음."""
|
||||
repo = CredFakeRepo()
|
||||
user = repo.create_user("alice")
|
||||
daemon = repo.create_daemon(user.id, daemon_slug="orb-bot")
|
||||
|
||||
enc = _make_encrypted()
|
||||
repo.upsert_broker_credential(user.id, "kis", account_ref="ref", encrypted_payload=enc, daemon_id=None)
|
||||
|
||||
runner = _build_runner(repo)
|
||||
result = runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "backfill-daemon-id",
|
||||
"--user", "alice",
|
||||
"--daemon-id", "orb-bot",
|
||||
"--dry-run",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# DB 변경 없음 (dry-run)
|
||||
creds = repo.list_broker_credentials(user.id)
|
||||
assert creds[0].daemon_id is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-048 AC-5: daemon_id IS NULL 없으면 "이미 완료됨"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_backfill_daemon_id_already_complete() -> None:
|
||||
"""REQ-048 AC-5: daemon_id IS NULL 없으면 이미 완료됨 + exit 0."""
|
||||
repo = CredFakeRepo()
|
||||
user = repo.create_user("alice")
|
||||
daemon = repo.create_daemon(user.id, daemon_slug="orb-bot")
|
||||
|
||||
enc = _make_encrypted()
|
||||
# 이미 daemon_id 있음
|
||||
repo.upsert_broker_credential(user.id, "kis", account_ref="ref", encrypted_payload=enc, daemon_id=daemon.id)
|
||||
|
||||
runner = _build_runner(repo)
|
||||
result = runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "backfill-daemon-id",
|
||||
"--user", "alice",
|
||||
"--daemon-id", "orb-bot",
|
||||
"--force",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "already_complete" in result.stdout or "이미 완료됨" in result.stdout
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-048 AC-6: 존재하지 않는 user → exit 1
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_backfill_daemon_id_nonexistent_user_exits_1() -> None:
|
||||
"""REQ-048 AC-6: 존재하지 않는 user --user → exit 1."""
|
||||
repo = CredFakeRepo()
|
||||
repo.create_daemon.__doc__ # dummy
|
||||
|
||||
runner = _build_runner(repo)
|
||||
result = runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "backfill-daemon-id",
|
||||
"--user", "nobody",
|
||||
"--daemon-id", "orb-bot",
|
||||
"--force",
|
||||
])
|
||||
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-048 AC-4: 백필 전후 encrypted_payload 동등 (데이터 손실 없음)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_backfill_preserves_encrypted_payload() -> None:
|
||||
"""REQ-048 AC-4: 백필 전후 encrypted_payload, account_ref 동등."""
|
||||
repo = CredFakeRepo()
|
||||
user = repo.create_user("alice")
|
||||
daemon = repo.create_daemon(user.id, daemon_slug="orb-bot")
|
||||
|
||||
enc = _make_encrypted("orig_key", "orig_secret")
|
||||
repo.upsert_broker_credential(
|
||||
user.id, "kis", account_ref="original-ref", encrypted_payload=enc, daemon_id=None
|
||||
)
|
||||
|
||||
original_encrypted = enc
|
||||
original_account_ref = "original-ref"
|
||||
|
||||
runner = _build_runner(repo)
|
||||
runner.invoke(runner.app, [
|
||||
"--format", "json", "credential", "backfill-daemon-id",
|
||||
"--user", "alice",
|
||||
"--daemon-id", "orb-bot",
|
||||
"--force",
|
||||
])
|
||||
|
||||
creds = repo.list_broker_credentials(user.id)
|
||||
assert creds[0].encrypted_payload == original_encrypted
|
||||
assert creds[0].account_ref == original_account_ref
|
||||
@@ -0,0 +1,436 @@
|
||||
"""BAT-020: REQ-049, REQ-051 — GET /v1/user_info daemon-scoped + 보안 격리 테스트."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.auth import AuthContext
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.encryption import encrypt_payload
|
||||
from app.domain.models import BrokerCredential, TokenPrincipal, User
|
||||
from app.main import create_app
|
||||
|
||||
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
|
||||
|
||||
def _make_settings(**kwargs) -> Settings:
|
||||
return Settings(
|
||||
allowed_origins_raw="http://localhost:3000",
|
||||
db_enabled=True,
|
||||
credential_encryption_key=_TEST_ENC_KEY,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _make_client(settings: Settings, mock_repo) -> TestClient:
|
||||
get_settings.cache_clear()
|
||||
app = create_app(settings_factory=lambda: settings)
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _svc_token(token: str = "svc-token") -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _make_cred(user_id: str, daemon_id: str, broker: str = "kis", account_ref: str = "ref") -> BrokerCredential:
|
||||
plain = json.dumps({"app_key": "k", "app_secret": "s"})
|
||||
encrypted = encrypt_payload(_TEST_ENC_KEY, plain)
|
||||
return BrokerCredential(
|
||||
user_id=user_id, broker=broker, account_ref=account_ref,
|
||||
encrypted_payload=encrypted, credential_version=1,
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-049 AC-1: daemon_id 있음 + broker 있음 → 200 + 단일 broker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_daemon_scoped_broker_returned() -> None:
|
||||
"""REQ-049 AC-1: daemon_id 바인딩 token + 해당 daemon broker 있음 → 단일 BrokerItem."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="u1", permission="read",
|
||||
token_type="service", runtime_id="rt-1", daemon_id="d1",
|
||||
)
|
||||
user = User(id="u1", username="alice", permission="read", is_active=True)
|
||||
cred = _make_cred("u1", "d1", account_ref="acc-d1")
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = [cred]
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["broker"] is not None
|
||||
assert body["broker"]["account_ref"] == "acc-d1"
|
||||
assert body["broker"]["broker"] == "kis"
|
||||
assert body["broker"]["credential_payload"] == {"app_key": "k", "app_secret": "s"}
|
||||
# REQ-049 AC-6: brokers 키 없음
|
||||
assert "brokers" not in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-049 AC-2: daemon_id 있음 + broker 없음 → 200 + broker=null
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_daemon_no_broker_returns_null() -> None:
|
||||
"""REQ-049 AC-2: daemon_id 있음, 해당 daemon 인증정보 없음 → broker=null."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="u1", permission="read",
|
||||
token_type="service", daemon_id="d1",
|
||||
)
|
||||
user = User(id="u1", username="alice", permission="read", is_active=True)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = [] # 인증정보 없음
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["broker"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-049 AC-3, REQ-051 AC-3: daemon_id 없는 service token → 403
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_no_daemon_id_returns_403() -> None:
|
||||
"""REQ-049 AC-3, REQ-051 AC-3: daemon_id=None service token → 403."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="u1", permission="read",
|
||||
token_type="service", daemon_id=None,
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-049 AC-4/5: 같은 user 2데몬 격리
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_daemon_isolation_two_daemons() -> None:
|
||||
"""REQ-049 AC-4/5: daemon A, B가 각각 다른 account_ref를 반환."""
|
||||
settings = _make_settings()
|
||||
user = User(id="u1", username="alice", permission="read", is_active=True)
|
||||
cred_a = _make_cred("u1", "d-a", account_ref="acc-A")
|
||||
cred_b = _make_cred("u1", "d-b", account_ref="acc-B")
|
||||
|
||||
principal_a = TokenPrincipal(
|
||||
token_id="ta", user_id="u1", permission="read",
|
||||
token_type="service", daemon_id="d-a",
|
||||
)
|
||||
principal_b = TokenPrincipal(
|
||||
token_id="tb", user_id="u1", permission="read",
|
||||
token_type="service", daemon_id="d-b",
|
||||
)
|
||||
|
||||
# daemon A 호출
|
||||
mock_repo_a = MagicMock()
|
||||
mock_repo_a.get_token_principal.return_value = principal_a
|
||||
mock_repo_a.get_user.return_value = user
|
||||
mock_repo_a.list_broker_credentials.return_value = [cred_a]
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo_a), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo_a):
|
||||
client = _make_client(settings, mock_repo_a)
|
||||
resp_a = client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp_a.status_code == 200
|
||||
assert resp_a.json()["broker"]["account_ref"] == "acc-A"
|
||||
|
||||
# daemon B 호출
|
||||
mock_repo_b = MagicMock()
|
||||
mock_repo_b.get_token_principal.return_value = principal_b
|
||||
mock_repo_b.get_user.return_value = user
|
||||
mock_repo_b.list_broker_credentials.return_value = [cred_b]
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo_b), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo_b):
|
||||
client_b = _make_client(settings, mock_repo_b)
|
||||
resp_b = client_b.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp_b.status_code == 200
|
||||
assert resp_b.json()["broker"]["account_ref"] == "acc-B"
|
||||
# 상호 반환 없음
|
||||
assert resp_a.json()["broker"]["account_ref"] != resp_b.json()["broker"]["account_ref"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-049 AC-6: 응답에 broker 단일 키 존재, brokers 리스트 없음
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_response_has_broker_not_brokers() -> None:
|
||||
"""REQ-049 AC-6: 응답 JSON에 broker 단일 키만 있고 brokers 키 없음."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="u1", permission="read",
|
||||
token_type="service", daemon_id="d1",
|
||||
)
|
||||
user = User(id="u1", username="alice", permission="read", is_active=True)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = []
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "broker" in body
|
||||
assert "brokers" not in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-049 AC-7, REQ-051 AC-7: 복호화 실패 → broker.credential_payload=null
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_decryption_failure_returns_null_payload() -> None:
|
||||
"""REQ-049 AC-7, REQ-051 AC-7: 복호화 실패 → broker.credential_payload=null, 크래시 없음."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="u1", permission="read",
|
||||
token_type="service", daemon_id="d1",
|
||||
)
|
||||
user = User(id="u1", username="alice", permission="read", is_active=True)
|
||||
# 잘못된 encrypted_payload (복호화 실패)
|
||||
cred = BrokerCredential(
|
||||
user_id="u1", broker="kis", account_ref="ref",
|
||||
encrypted_payload="INVALID_PAYLOAD",
|
||||
credential_version=1, daemon_id="d1",
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = [cred]
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["broker"] is not None
|
||||
assert body["broker"]["account_ref"] == "ref"
|
||||
assert body["broker"]["credential_payload"] is None # 복호화 실패
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-051 AC-1: web 토큰 → 403
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_web_token_returns_403() -> None:
|
||||
"""REQ-051 AC-1: web user 토큰(token_type!=service) → 403."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="u1", permission="read",
|
||||
token_type="user", # service 아님
|
||||
daemon_id="d1",
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-051 AC-2: 비인증 → 401
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_unauthenticated_returns_401() -> None:
|
||||
"""REQ-051 AC-2: Authorization 헤더 없음 → 401."""
|
||||
settings = _make_settings()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = None # 토큰 없음
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info") # 헤더 없음
|
||||
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-051 AC-4: 다른 daemon 인증정보 → null (데이터 격리)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_different_daemon_credential_not_returned() -> None:
|
||||
"""REQ-051 AC-4: daemon_id="d1" token이 daemon_id="d2" 인증정보를 받지 않음."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="u1", permission="read",
|
||||
token_type="service", daemon_id="d1",
|
||||
)
|
||||
user = User(id="u1", username="alice", permission="read", is_active=True)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
# daemon_id="d1" 필터로 조회하면 빈 결과 (DB에는 d2만 있음)
|
||||
mock_repo.list_broker_credentials.return_value = []
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["broker"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-051 AC-5: 타 엔드포인트 응답에 credential_payload 미포함
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_other_endpoints_do_not_expose_credential_payload(monkeypatch) -> None:
|
||||
"""REQ-051 AC-5: GET /health, /v1/portfolios 등에 credential 필드 없음."""
|
||||
settings = _make_settings()
|
||||
client = TestClient(create_app(settings_factory=lambda: settings))
|
||||
|
||||
# /health 응답 검사
|
||||
resp = client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "credential_payload" not in body
|
||||
assert "app_secret" not in body
|
||||
assert "app_key" not in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-051 AC-6: DB 오류 → broker=null graceful degrade (500 없음)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_db_error_returns_null_broker() -> None:
|
||||
"""REQ-051 AC-6: list_broker_credentials DB 오류 → broker=null, 500 없음."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="u1", permission="read",
|
||||
token_type="service", daemon_id="d1",
|
||||
)
|
||||
user = User(id="u1", username="alice", permission="read", is_active=True)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.side_effect = RuntimeError("DB connection error")
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["broker"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-051 AC-8: 복호화 실패 시 로그에 app_secret 미노출
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_decryption_failure_log_does_not_expose_secret(caplog) -> None:
|
||||
"""REQ-051 AC-8: 복호화 실패 로그에 app_key, app_secret 값 미포함."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="u1", permission="read",
|
||||
token_type="service", daemon_id="d1",
|
||||
)
|
||||
user = User(id="u1", username="alice", permission="read", is_active=True)
|
||||
# 잘못된 encrypted_payload
|
||||
cred = BrokerCredential(
|
||||
user_id="u1", broker="kis", account_ref="ref",
|
||||
encrypted_payload="INVALID_PAYLOAD_SECRET_VALUE",
|
||||
credential_version=1, daemon_id="d1",
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = [cred]
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="app.api.v1_user_info"), \
|
||||
patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
# 로그에 app_secret, app_key 값이 없음 확인
|
||||
log_text = " ".join(caplog.messages)
|
||||
assert "mysecret" not in log_text
|
||||
assert "mykey" not in log_text
|
||||
# 로그에 INVALID_PAYLOAD 원문이 없음 (payload 노출 아님)
|
||||
# 단지 오류 유형(class name)만 기록
|
||||
assert "INVALID_PAYLOAD_SECRET_VALUE" not in log_text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-049 AC-9: 기존 필드들 회귀 없음
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_info_regression_existing_fields_unchanged() -> None:
|
||||
"""REQ-049 AC-9: broker 변경이 runtime_id, daemon_id, trading_enabled 등에 영향 없음."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="u1", permission="read",
|
||||
token_type="service", runtime_id="rt-xyz", daemon_id="d1",
|
||||
)
|
||||
user = User(id="u1", username="alice", email="alice@example.com", permission="read", is_active=True)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = []
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers=_svc_token())
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["runtime_id"] == "rt-xyz"
|
||||
assert body["daemon_id"] == "d1"
|
||||
assert body["trading_enabled"] is True
|
||||
assert body["user_id"] == "u1"
|
||||
assert body["username"] == "alice"
|
||||
assert body["permission"] == "read"
|
||||
+64
-13
@@ -175,6 +175,7 @@ class FakeRepo:
|
||||
*,
|
||||
account_ref: str,
|
||||
encrypted_payload: str,
|
||||
daemon_id: str | None = None,
|
||||
) -> BrokerCredential:
|
||||
self.get_user(user_id)
|
||||
key = (user_id, broker)
|
||||
@@ -186,17 +187,65 @@ class FakeRepo:
|
||||
encrypted_payload=encrypted_payload,
|
||||
credential_version=(existing.credential_version + 1) if existing is not None else 1,
|
||||
last_rotated_at=datetime.now(timezone.utc),
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
self._broker_credentials[key] = credential
|
||||
return credential
|
||||
|
||||
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]:
|
||||
return sorted(
|
||||
[credential for (owner_user_id, _), credential in self._broker_credentials.items() if owner_user_id == user_id],
|
||||
key=lambda credential: credential.broker,
|
||||
def list_broker_credentials(
|
||||
self,
|
||||
user_id: str,
|
||||
daemon_id: str | None = None,
|
||||
) -> list[BrokerCredential]:
|
||||
creds = [
|
||||
credential
|
||||
for (owner_user_id, _), credential in self._broker_credentials.items()
|
||||
if owner_user_id == user_id
|
||||
]
|
||||
if daemon_id is not None:
|
||||
creds = [c for c in creds if c.daemon_id == daemon_id]
|
||||
return sorted(creds, key=lambda credential: credential.broker)
|
||||
|
||||
def update_broker_credential_daemon_id(
|
||||
self,
|
||||
user_id: str,
|
||||
broker: str,
|
||||
daemon_id: str,
|
||||
) -> bool:
|
||||
key = (user_id, broker)
|
||||
cred = self._broker_credentials.get(key)
|
||||
if cred is None or cred.daemon_id is not None:
|
||||
return False
|
||||
self._broker_credentials[key] = BrokerCredential(
|
||||
user_id=cred.user_id,
|
||||
broker=cred.broker,
|
||||
account_ref=cred.account_ref,
|
||||
encrypted_payload=cred.encrypted_payload,
|
||||
credential_version=cred.credential_version,
|
||||
last_rotated_at=cred.last_rotated_at,
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
return True
|
||||
|
||||
def get_daemon_by_slug(self, owner_user_id: str, daemon_slug: str):
|
||||
return next(
|
||||
(d for d in self._daemons.values() if d.owner_user_id == owner_user_id and d.daemon_slug == daemon_slug),
|
||||
None,
|
||||
)
|
||||
|
||||
def delete_broker_credential(self, user_id: str, broker: str) -> bool:
|
||||
def delete_broker_credential(
|
||||
self,
|
||||
user_id: str,
|
||||
broker: str,
|
||||
daemon_id: str | None = None,
|
||||
) -> bool:
|
||||
if daemon_id is not None:
|
||||
# daemon_id 기반 삭제
|
||||
for key, cred in list(self._broker_credentials.items()):
|
||||
if cred.daemon_id == daemon_id and cred.broker == broker:
|
||||
del self._broker_credentials[key]
|
||||
return True
|
||||
return False
|
||||
key = (user_id, broker)
|
||||
if key not in self._broker_credentials:
|
||||
return False
|
||||
@@ -986,8 +1035,10 @@ def test_cli_daemon_create_and_list_render_entries() -> None:
|
||||
|
||||
|
||||
def test_cli_credential_set_list_and_remove_flow() -> None:
|
||||
"""REQ-050: --daemon-id 포함 credential set/list/remove 플로우 (BAT-020 갱신)."""
|
||||
repo = FakeRepo(_portfolio())
|
||||
user = repo.create_user("alice", permission="read")
|
||||
daemon = repo.create_daemon(user.id, daemon_slug="orb-bot")
|
||||
runner = build_runner(repo)
|
||||
|
||||
set_result = runner.invoke(
|
||||
@@ -999,6 +1050,8 @@ def test_cli_credential_set_list_and_remove_flow() -> None:
|
||||
"set",
|
||||
"--user",
|
||||
"alice",
|
||||
"--daemon-id",
|
||||
"orb-bot",
|
||||
"--broker",
|
||||
"kis",
|
||||
"--account-ref",
|
||||
@@ -1023,18 +1076,16 @@ def test_cli_credential_set_list_and_remove_flow() -> None:
|
||||
set_payload = json.loads(set_result.stdout)
|
||||
assert set_payload["user_id"] == user.id
|
||||
assert set_payload["broker"] == "kis"
|
||||
assert set_payload["daemon_id"] == daemon.id
|
||||
assert set_payload["credential_version"] == 1
|
||||
|
||||
assert list_result.exit_code == 0
|
||||
list_payload = json.loads(list_result.stdout)
|
||||
assert list_payload == [
|
||||
{
|
||||
"broker": "kis",
|
||||
"account_ref": "123-45",
|
||||
"credential_version": 1,
|
||||
"last_rotated_at": list_payload[0]["last_rotated_at"],
|
||||
}
|
||||
]
|
||||
assert len(list_payload) == 1
|
||||
assert list_payload[0]["broker"] == "kis"
|
||||
assert list_payload[0]["account_ref"] == "123-45"
|
||||
assert list_payload[0]["daemon_id"] == daemon.id
|
||||
assert list_payload[0]["credential_version"] == 1
|
||||
|
||||
assert remove_result.exit_code == 0
|
||||
remove_payload = json.loads(remove_result.stdout)
|
||||
|
||||
@@ -120,7 +120,8 @@ def test_user_info_returns_daemon_id_in_response(monkeypatch) -> None:
|
||||
assert resp.json()["daemon_id"] == "d-1"
|
||||
|
||||
|
||||
def test_user_info_null_daemon_id_returns_200_with_null(monkeypatch) -> None:
|
||||
def test_user_info_null_daemon_id_returns_403(monkeypatch) -> None:
|
||||
"""REQ-049 AC-3: daemon_id=None service token → 403 Forbidden (BAT-020 정책 변경)."""
|
||||
client = _app_with_db(monkeypatch)
|
||||
principal = _principal(daemon_id=None)
|
||||
user = User(id="user-1", username="alice", permission="read", is_active=True)
|
||||
@@ -134,8 +135,8 @@ def test_user_info_null_daemon_id_returns_200_with_null(monkeypatch) -> None:
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
resp = client.get("/v1/user_info", headers=_svc_header())
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["daemon_id"] is None
|
||||
# REQ-049: daemon_id 없는 service token은 user_info 403 (daemon-scoped 인가)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+63
-24
@@ -118,7 +118,7 @@ class _RuntimeIdRoundTripRepo:
|
||||
daemon_id=token.daemon_id,
|
||||
)
|
||||
|
||||
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]:
|
||||
def list_broker_credentials(self, user_id: str, daemon_id: str | None = None) -> list[BrokerCredential]:
|
||||
return []
|
||||
|
||||
|
||||
@@ -144,6 +144,9 @@ def test_health_returns_full_response() -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_auth_issue_service_token_returns_runtime_id(monkeypatch) -> None:
|
||||
# REQ-049: daemon_id 없는 service token → user_info 403
|
||||
# 이 테스트는 service token 발급의 runtime_id 생성을 검증하고,
|
||||
# daemon_id 없는 token으로 user_info 호출 시 403을 확인한다.
|
||||
repo = _RuntimeIdRoundTripRepo()
|
||||
settings = _make_settings(
|
||||
db_enabled=True,
|
||||
@@ -168,15 +171,13 @@ def test_auth_issue_service_token_returns_runtime_id(monkeypatch) -> None:
|
||||
assert issue_body["runtime_id"] is not None
|
||||
assert "token" in issue_body
|
||||
|
||||
# REQ-049 AC-3: daemon_id 없는 service token → user_info 403
|
||||
user_info_resp = client.get(
|
||||
"/v1/user_info",
|
||||
headers=_service_token_header(issue_body["token"]),
|
||||
)
|
||||
|
||||
assert user_info_resp.status_code == 200
|
||||
user_info_body = user_info_resp.json()
|
||||
assert user_info_body["runtime_id"] == issue_body["runtime_id"]
|
||||
assert user_info_body["username"] == "alice"
|
||||
assert user_info_resp.status_code == 403
|
||||
|
||||
|
||||
def test_auth_issue_service_token_generates_distinct_runtime_ids_per_token(monkeypatch) -> None:
|
||||
@@ -214,11 +215,12 @@ def test_auth_issue_service_token_generates_distinct_runtime_ids_per_token(monke
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None:
|
||||
# REQ-049: daemon_id 있음 + runtime_id=None → 200 반환
|
||||
settings = _make_app_db(monkeypatch)
|
||||
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id=None, # no runtime_id
|
||||
token_type="service", runtime_id=None, daemon_id="daemon-1",
|
||||
)
|
||||
user = User(id="user-1", username="alice", permission="read", is_active=True)
|
||||
|
||||
@@ -237,11 +239,12 @@ def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None
|
||||
|
||||
|
||||
def test_v1_user_info_returns_user(monkeypatch) -> None:
|
||||
# REQ-049: daemon_id 있음 + broker=None → 200 반환
|
||||
settings = _make_app_db(monkeypatch)
|
||||
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id="rt-abc"
|
||||
token_type="service", runtime_id="rt-abc", daemon_id="daemon-1",
|
||||
)
|
||||
user = User(
|
||||
id="user-1", username="alice", email="alice@example.com",
|
||||
@@ -264,15 +267,39 @@ def test_v1_user_info_returns_user(monkeypatch) -> None:
|
||||
assert body["username"] == "alice"
|
||||
assert body["trading_enabled"] is True
|
||||
assert body["disabled_reason"] is None
|
||||
assert body["brokers"] == []
|
||||
# REQ-049 AC-6: broker 단일 필드 존재, brokers 리스트 미존재
|
||||
assert "broker" in body
|
||||
assert "brokers" not in body
|
||||
assert body["broker"] is None # 인증정보 없음 → null
|
||||
|
||||
|
||||
def test_v1_user_info_no_daemon_id_returns_403(monkeypatch) -> None:
|
||||
"""REQ-049 AC-3, REQ-051 AC-3: daemon_id=None 서비스 토큰 → 403."""
|
||||
settings = _make_app_db(monkeypatch)
|
||||
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id="rt-abc", daemon_id=None,
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
|
||||
resp = client.get("/v1/user_info", headers=_service_token_header())
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None:
|
||||
# daemon_id 있음 + 비활성 user → disabled_reason
|
||||
settings = _make_app_db(monkeypatch)
|
||||
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-2", permission="read",
|
||||
token_type="service", runtime_id="rt-xyz"
|
||||
token_type="service", runtime_id="rt-xyz", daemon_id="daemon-2",
|
||||
)
|
||||
user = User(
|
||||
id="user-2", username="bob", email=None,
|
||||
@@ -295,8 +322,8 @@ def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None:
|
||||
assert body["disabled_reason"] == "account_inactive"
|
||||
|
||||
|
||||
def test_v1_user_info_returns_decrypted_broker_credentials(monkeypatch) -> None:
|
||||
"""REQ-007 AC5: brokers[]에 복호화된 credential_payload 반환."""
|
||||
def test_v1_user_info_returns_decrypted_broker_credential(monkeypatch) -> None:
|
||||
"""REQ-049 AC-1: daemon_id 있음 + broker 인증정보 있음 → 단일 broker 반환."""
|
||||
from app.core.encryption import encrypt_payload
|
||||
import json as _json
|
||||
|
||||
@@ -307,12 +334,13 @@ def test_v1_user_info_returns_decrypted_broker_credentials(monkeypatch) -> None:
|
||||
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id="rt-abc"
|
||||
token_type="service", runtime_id="rt-abc", daemon_id="daemon-1",
|
||||
)
|
||||
user = User(id="user-1", username="alice", permission="read", is_active=True)
|
||||
cred = BrokerCredential(
|
||||
user_id="user-1", broker="kis", account_ref="12345678",
|
||||
encrypted_payload=encrypted, credential_version=1,
|
||||
daemon_id="daemon-1",
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
@@ -331,8 +359,11 @@ def test_v1_user_info_returns_decrypted_broker_credentials(monkeypatch) -> None:
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert len(body["brokers"]) == 1
|
||||
broker = body["brokers"][0]
|
||||
# REQ-049 AC-6: broker 단일 필드, brokers 리스트 없음
|
||||
assert "broker" in body
|
||||
assert "brokers" not in body
|
||||
broker = body["broker"]
|
||||
assert broker is not None
|
||||
assert broker["broker"] == "kis"
|
||||
assert broker["credential_payload"] == {"app_key": "mykey", "app_secret": "mysecret"}
|
||||
|
||||
@@ -793,12 +824,13 @@ def test_v1_assets_read_token_200(monkeypatch) -> None:
|
||||
# GET /v1/user_info
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None:
|
||||
def test_v1_user_info_null_runtime_id_returns_200_with_null_b(monkeypatch) -> None:
|
||||
# REQ-049: daemon_id 있음 + runtime_id=None → 200
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
||||
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id=None, # no runtime_id
|
||||
token_type="service", runtime_id=None, daemon_id="daemon-1",
|
||||
)
|
||||
user = User(id="user-1", username="alice", permission="read", is_active=True)
|
||||
|
||||
@@ -816,12 +848,13 @@ def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None
|
||||
assert resp.json()["runtime_id"] is None
|
||||
|
||||
|
||||
def test_v1_user_info_returns_user(monkeypatch) -> None:
|
||||
def test_v1_user_info_returns_user_b(monkeypatch) -> None:
|
||||
# REQ-049 AC-9 회귀: daemon_id 있음 → 기존 필드들 정상 반환
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
||||
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id="rt-abc"
|
||||
token_type="service", runtime_id="rt-abc", daemon_id="daemon-1",
|
||||
)
|
||||
user = User(
|
||||
id="user-1", username="alice", email="alice@example.com",
|
||||
@@ -831,6 +864,7 @@ def test_v1_user_info_returns_user(monkeypatch) -> None:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = []
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
@@ -842,12 +876,13 @@ def test_v1_user_info_returns_user(monkeypatch) -> None:
|
||||
assert body["runtime_id"] == "rt-abc"
|
||||
assert body["username"] == "alice"
|
||||
assert body["disabled_reason"] is None
|
||||
# REQ-049 AC-6: broker 단일 필드, brokers 키 없음
|
||||
assert "broker" in body
|
||||
assert "brokers" not in body
|
||||
|
||||
|
||||
def test_v1_user_info_degrades_when_broker_credentials_lookup_fails(monkeypatch) -> None:
|
||||
"""issue #9 regression: broker_credentials lookup failure (missing table / DB error)
|
||||
must NOT crash /v1/user_info with 500. daemon_id/user_id/permission do not depend on
|
||||
broker credentials — degrade gracefully with empty brokers."""
|
||||
"""REQ-049 AC-2, REQ-051 AC-6: broker_credentials lookup failure → broker=null graceful degrade."""
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
||||
|
||||
principal = TokenPrincipal(
|
||||
@@ -872,15 +907,18 @@ def test_v1_user_info_degrades_when_broker_credentials_lookup_fails(monkeypatch)
|
||||
body = resp.json()
|
||||
assert body["daemon_id"] == "daemon-9"
|
||||
assert body["user_id"] == "user-1"
|
||||
assert body["brokers"] == []
|
||||
# REQ-049 AC-2: broker=null (graceful degrade, 크래시 없음)
|
||||
assert body["broker"] is None
|
||||
assert "brokers" not in body
|
||||
|
||||
|
||||
def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None:
|
||||
def test_v1_user_info_inactive_user_has_disabled_reason_b(monkeypatch) -> None:
|
||||
# daemon_id 있음 + 비활성 user
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
||||
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-2", permission="read",
|
||||
token_type="service", runtime_id="rt-xyz"
|
||||
token_type="service", runtime_id="rt-xyz", daemon_id="daemon-2",
|
||||
)
|
||||
user = User(
|
||||
id="user-2", username="bob", email=None,
|
||||
@@ -890,6 +928,7 @@ def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = []
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
|
||||
Reference in New Issue
Block a user