diff --git a/app/api/v1_user_info.py b/app/api/v1_user_info.py index d2f507f..e49bf13 100644 --- a/app/api/v1_user_info.py +++ b/app/api/v1_user_info.py @@ -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() + 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, diff --git a/app/cli/commands/credential.py b/app/cli/commands/credential.py index b0ed43b..01c0214 100644 --- a/app/cli/commands/credential.py +++ b/app/cli/commands/credential.py @@ -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) diff --git a/app/domain/models.py b/app/domain/models.py index 15d4213..d4d6372 100644 --- a/app/domain/models.py +++ b/app/domain/models.py @@ -182,4 +182,5 @@ class BrokerCredential: encrypted_payload: str credential_version: int = 1 last_rotated_at: datetime | None = None + daemon_id: str | None = None diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 7f86af3..e250e76 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -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,50 +740,134 @@ 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("사용자를 찾을 수 없습니다") - cur.execute( - """ - INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload) - VALUES (%s, %s, %s, %s) - ON CONFLICT (user_id, broker) DO UPDATE - SET account_ref = EXCLUDED.account_ref, - encrypted_payload = EXCLUDED.encrypted_payload, - credential_version = broker_credentials.credential_version + 1, - last_rotated_at = NOW() - RETURNING user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at - """, - (user_id, broker, account_ref, encrypted_payload), - ) + # 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) + VALUES (%s, %s, %s, %s) + ON CONFLICT (user_id, broker) DO UPDATE + SET account_ref = EXCLUDED.account_ref, + encrypted_payload = EXCLUDED.encrypted_payload, + credential_version = broker_credentials.credential_version + 1, + last_rotated_at = NOW() + RETURNING user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at + """, + (user_id, broker, account_ref, encrypted_payload), + ) 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 + FROM broker_credentials + WHERE user_id = %s + ORDER BY broker ASC + """, + (user_id,), + ) + rows = cur.fetchall() + return [self._row_to_broker_credential(row) for row in rows] + + 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( """ - SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at - FROM broker_credentials - WHERE user_id = %s - ORDER BY broker ASC + UPDATE broker_credentials + SET daemon_id = %s + WHERE user_id = %s AND broker = %s AND daemon_id IS NULL + RETURNING user_id """, - (user_id,), + (daemon_id, user_id, broker), ) - rows = cur.fetchall() - return [self._row_to_broker_credential(row) for row in rows] + row = cur.fetchone() + return row is not 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: self.ensure_schema() with self._connect().cursor() as cur: - cur.execute( - "DELETE FROM broker_credentials WHERE user_id = %s AND broker = %s RETURNING user_id", - (user_id, broker), - ) + 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), + ) row = cur.fetchone() return row is not None diff --git a/app/schemas/v1.py b/app/schemas/v1.py index b2971dd..6aa948c 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -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 diff --git a/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/design.md b/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/design.md new file mode 100644 index 0000000..d0829b1 --- /dev/null +++ b/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/design.md @@ -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 --daemon-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 `: daemon_slug로 먼저 시도 (`get_daemon_by_slug(owner_user_id, value)`), 실패 시 `get_daemon_by_id(value)` 재시도 + - `--user `으로 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 추가 | diff --git a/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/index.md b/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/index.md new file mode 100644 index 0000000..4be71bc --- /dev/null +++ b/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/index.md @@ -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 검증까지만 수행. diff --git a/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/planning.md b/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/planning.md new file mode 100644 index 0000000..13402b8 --- /dev/null +++ b/docs/batches/bat-020_20260628_daemon-scoped-broker-credential/planning.md @@ -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 조회 먼저 필요 diff --git a/migrations/versions/20260628_0026_broker_credential_daemon_id_step1.py b/migrations/versions/20260628_0026_broker_credential_daemon_id_step1.py new file mode 100644 index 0000000..2386e7a --- /dev/null +++ b/migrations/versions/20260628_0026_broker_credential_daemon_id_step1.py @@ -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") diff --git a/migrations/versions/20260628_0027_broker_credential_daemon_id_step2.py b/migrations/versions/20260628_0027_broker_credential_daemon_id_step2.py new file mode 100644 index 0000000..6e95c73 --- /dev/null +++ b/migrations/versions/20260628_0027_broker_credential_daemon_id_step2.py @@ -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"], + ) diff --git a/tests/test_bat020_broker_credential_domain.py b/tests/test_bat020_broker_credential_domain.py new file mode 100644 index 0000000..4c9219b --- /dev/null +++ b/tests/test_bat020_broker_credential_domain.py @@ -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 diff --git a/tests/test_bat020_credential_cli.py b/tests/test_bat020_credential_cli.py new file mode 100644 index 0000000..5bcfa4f --- /dev/null +++ b/tests/test_bat020_credential_cli.py @@ -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 diff --git a/tests/test_bat020_user_info_daemon_scoped.py b/tests/test_bat020_user_info_daemon_scoped.py new file mode 100644 index 0000000..596ca92 --- /dev/null +++ b/tests/test_bat020_user_info_daemon_scoped.py @@ -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" diff --git a/tests/test_cli.py b/tests/test_cli.py index d8cf9d8..7f22b01 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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) diff --git a/tests/test_daemon_assignment.py b/tests/test_daemon_assignment.py index a6c41df..1723591 100644 --- a/tests/test_daemon_assignment.py +++ b/tests/test_daemon_assignment.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/test_v1.py b/tests/test_v1.py index c3163bd..31e4c52 100644 --- a/tests/test_v1.py +++ b/tests/test_v1.py @@ -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):