Merge pull request #16 from fourblocks2020/feat/dcy-009-user-read-endpoints

feat(dcy-009): 사용자용 read 엔드포인트 — GET /v1/assets require_read 완화 + GET /v1/daemons
This commit is contained in:
2026-06-27 10:41:50 +09:00
committed by GitHub
20 changed files with 1187 additions and 40 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.core.auth import AuthContext, require_admin
from app.core.auth import AuthContext, require_admin, require_read
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.v1 import (
AssetCatalogCreateRequest,
@@ -76,7 +76,7 @@ _CATALOG_STATUS_FILTERS = {"active", "inactive", "all"}
def list_catalog_assets(
status_filter: str = Query(default="active", alias="status"),
market: str | None = Query(default=None),
auth: AuthContext = Depends(require_admin),
auth: AuthContext = Depends(require_read),
) -> AssetCatalogListResponse:
if status_filter not in _CATALOG_STATUS_FILTERS:
raise HTTPException(
+56
View File
@@ -0,0 +1,56 @@
"""v1 daemon list endpoint — caller-scoped daemon list (REQ-045)."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends
from app.core.auth import AuthContext, require_read
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.v1 import DaemonListResponse, DaemonResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1", tags=["v1"])
def _get_repo(settings=None):
from app.core.config import get_settings
return get_portfolio_repository_from_settings(settings or get_settings())
# ---------------------------------------------------------------------------
# GET /v1/daemons — caller-scoped daemon list (REQ-045)
# ---------------------------------------------------------------------------
@router.get("/daemons", response_model=DaemonListResponse)
def list_daemons(
auth: AuthContext = Depends(require_read),
) -> DaemonListResponse:
"""Return all daemons owned by the caller (auth.user_id).
- Caller scope only: returns daemons belonging to auth.user_id.
- admin token with user_id also returns only that user's daemons.
- env/service tokens with user_id=None return an empty list (HTTP 200).
- Returns all daemons (active and inactive) with is_active field for distinction.
"""
if auth.user_id is None:
return DaemonListResponse(daemons=[], total=0)
repo = _get_repo()
try:
daemons = repo.list_daemons_for_user(auth.user_id)
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
items = [
DaemonResponse(
id=d.id,
daemon_slug=d.daemon_slug,
display_name=d.display_name,
is_active=d.is_active,
)
for d in daemons
]
return DaemonListResponse(daemons=items, total=len(items))
+2
View File
@@ -12,6 +12,7 @@ from app.api.portfolios import router as portfolio_router
from app.api.strategy_sets import router as strategy_sets_router
from app.api.users import router as users_router
from app.api.v1_assets import router as v1_assets_router
from app.api.v1_daemons import router as v1_daemons_router
from app.api.v1_portfolios import router as v1_portfolios_router
from app.api.v1_tags import router as v1_tags_router
from app.api.v1_user_info import router as v1_user_info_router
@@ -81,6 +82,7 @@ def create_app(
app.include_router(strategy_sets_router)
app.include_router(users_router)
app.include_router(v1_assets_router)
app.include_router(v1_daemons_router)
app.include_router(v1_portfolios_router)
app.include_router(v1_tags_router)
app.include_router(v1_user_info_router)
+16
View File
@@ -310,3 +310,19 @@ class HoldingsCostBasisResponse(BaseModel):
total_cost_basis: float | None = None
cost_basis_from: datetime | None = None
data_completeness_warning: str | None = None
# ---------------------------------------------------------------------------
# GET /v1/daemons (REQ-045)
# ---------------------------------------------------------------------------
class DaemonResponse(BaseModel):
id: str
daemon_slug: str
display_name: str | None = None
is_active: bool
class DaemonListResponse(BaseModel):
daemons: list[DaemonResponse]
total: int
@@ -0,0 +1,77 @@
# BAT-019 Design
- Status: confirmed
- Batch: BAT-019
## Architecture Summary
이 batch는 두 가지 독립적인 변경으로 구성된다.
1. **REQ-044**: `GET /v1/assets`의 인증 게이트를 `require_admin` → `require_read`로 완화.
기존 `AssetCatalogListResponse` 응답 구조, 필터 파라미터, 라우터 등록은 모두 불변.
2. **REQ-045**: `GET /v1/daemons` 신규 엔드포인트.
caller(`auth.user_id`) 소유 데몬만 반환. `require_read` 인증.
신규 파일: `app/api/v1_daemons.py`, 신규 스키마: `DaemonResponse`, `DaemonListResponse`.
`app/main.py`에 라우터 등록.
## Changed Areas
- Docs: `docs/batches/bat-019_20260627_assets-read-permission-and-daemon-list/`
- Modules:
- `app/api/v1_assets.py` (require_admin → require_read, list_catalog_assets만)
- `app/api/v1_daemons.py` (신규)
- `app/schemas/v1.py` (DaemonResponse, DaemonListResponse 추가)
- `app/main.py` (v1_daemons 라우터 include_router 추가)
- Interfaces:
- `GET /v1/assets`: 기존 인터페이스 불변, 인증 게이트만 완화
- `GET /v1/daemons`: 신규 엔드포인트
## Key Decisions
1. **REQ-044 — 권한 완화 범위**: `list_catalog_assets`(GET /v1/assets) 1개만 `require_read`로 교체.
POST /v1/assets, PATCH /{id}, PATCH /{id}/deactivate, POST /{id}/tags, DELETE /{id}/tags/{tid}는 `require_admin` 유지.
응답 스키마 `AssetCatalogListResponse`는 불변.
2. **REQ-045 — 경로**: `GET /v1/daemons`.
`/v1/user/daemons` 대신 `/v1/daemons` 채택 — 기존 `/v1/tags`, `/v1/assets`, `/v1/portfolios` 패턴과 일치.
3. **REQ-045 — 응답 스키마**: `DaemonListResponse{daemons: list[DaemonResponse], total: int}`.
`DaemonResponse` 필드: `id`(str), `daemon_slug`(str), `display_name`(str|None), `is_active`(bool).
기존 `TagListResponse{tags: list[TagItem], total: int}` 패턴과 동일 구조.
4. **REQ-045 — env/service 토큰(user_id=None) 처리**: `auth.user_id`가 None이면 빈 목록 `[]` + `total: 0`을 HTTP 200으로 반환.
이유: 환경 변수 토큰 또는 user_id 없는 서비스 토큰은 특정 사용자 소유 데몬이 없으므로 에러보다 빈 응답이 자연스럽다.
기존 `/v1/user_info`의 env 토큰 처리(403 Service token required)와 다른 접근이며, 데몬 목록은 사용자 식별 없이도 빈 목록을 반환하는 것이 호출자 친화적이다.
5. **REQ-045 — active 필터**: 기본적으로 소유 데몬 전체 반환(active/inactive 모두). `is_active` 필드로 구분 표시.
이유: caller가 비활성 데몬도 인지해야 할 수 있으며(재활성화 등), 필터는 UI 레이어에서 처리 가능.
6. **REQ-045 — AuthService 활용**: `AuthService.find_candidate_daemons_for_user(user_id)` 호출.
단, 라우터에서 직접 repository를 호출하는 기존 패턴(v1_assets, v1_tags 등)과 일관성을 위해
라우터에서 직접 `repo.list_daemons_for_user(user_id)`를 호출하는 방식 채택
(service 레이어 DI overhead 없이 기존 패턴 유지).
7. **REQ-044 테스트 영향**: 기존 테스트 중 `test_get_v1_assets_user_token_returns_403`,
`test_get_v1_assets_service_token_returns_403`는 이번 완화로 동작이 바뀜.
— user 토큰(permission="read"): 이제 200이어야 함 → 테스트 업데이트.
— service 토큰(permission="trade-write"): trade-write는 read 포함(_has_permission 로직상)이므로 200이어야 함 → 테스트 업데이트.
또한 `test_v1_assets_rejects_env_token`(test_v1.py): env read 토큰도 이제 200이어야 함 → 업데이트.
`test_v1_assets_returns_asset_list`: read 토큰이 이제 403이 아닌 200 → 업데이트.
`test_v1_assets_market_filter`, `test_v1_assets_asset_id_is_uuid`: 동일.
## Edge Cases
- `user_id=None`(env 토큰): 빈 목록 200 반환 (결정 #4).
- 데몬 없는 사용자: 빈 목록 `[]` 200 반환.
- admin 토큰도 caller 스코프: admin 토큰의 `user_id`가 None이면 빈 목록, user_id가 있으면 해당 user 소유 데몬만.
- `list_daemons_for_user` DB 미활성(db_enabled=False): 빈 목록 반환 또는 InMemory 구현에 따름.
## Architecture Impact
- none (라우터 추가는 runtime-flows 수준 변경이 아님; project-structure에 신규 파일 기록 수준)
## Reference Doc Update Plan
- project-structure.md: none (파일 추가 수준, 별도 문서화 불필요)
- runtime-flows.md: none
@@ -0,0 +1,39 @@
# BAT-019 Implementation
- Status: completed
- Batch: BAT-019
## Plan Summary
- REQ-044: `app/api/v1_assets.py`의 `list_catalog_assets` 의존성을 `require_admin` → `require_read`로 교체.
- REQ-045: `app/schemas/v1.py`에 `DaemonResponse`/`DaemonListResponse` 추가, `app/api/v1_daemons.py` 신규 생성, `app/main.py`에 라우터 등록.
- 테스트: `tests/test_v1.py` 기존 403 기대 케이스 업데이트, `tests/test_asset_catalog.py` 기존 403 기대 케이스 업데이트, `tests/test_v1_daemons.py` 신규 생성.
## Changed Files
- `app/api/v1_assets.py`: `require_read` import 추가, `list_catalog_assets` 의존성 교체 (require_admin → require_read)
- `app/api/v1_daemons.py`: 신규 생성 — GET /v1/daemons 라우터
- `app/schemas/v1.py`: `DaemonResponse`, `DaemonListResponse` 스키마 추가
- `app/main.py`: `v1_daemons_router` import 및 `include_router` 추가
- `tests/test_v1.py`: 5개 테스트 업데이트 (403→200 기대치 변경, 일부 내용/이름 변경)
- `tests/test_asset_catalog.py`: 2개 테스트 삭제 후 3개로 교체 (REQ-044 반영 + 무토큰 401 추가)
- `tests/test_v1_daemons.py`: 신규 생성 — REQ-045 AC 전체 + REQ-044 회귀 커버
## Execution Log
1. `app/api/v1_assets.py`: `require_read` import, `list_catalog_assets` Depends 교체
2. `app/schemas/v1.py`: DaemonResponse, DaemonListResponse 스키마 추가 (파일 말미)
3. `app/api/v1_daemons.py`: 신규 작성 (GET /v1/daemons, user_id=None → 빈 목록 200)
4. `app/main.py`: v1_daemons_router import + include_router
5. `tests/test_v1.py`: 영향받는 5개 테스트 업데이트
6. `tests/test_asset_catalog.py`: 영향받는 2개 테스트 → 3개로 교체
7. `tests/test_v1_daemons.py`: 신규 생성 (13개 테스트)
8. pytest 전체 실행 → 결과 verification.md에 기록
## Validation
- pytest 전체 실행 결과: verification.md 참조
## Remaining Risks
- `list_daemons_for_user`는 DB 라이브 테스트 없이 mock으로만 검증됨 (DB 통합 테스트는 비활성화 환경 밖에서 필요)
@@ -0,0 +1,22 @@
# BAT-019 Index
- Status: in-delivery
- Profile: standard
- Batch: BAT-019
- Discovery: dcy-009
- Created: 2026-06-27
## Included REQ
- REQ-044: GET /v1/assets 권한 완화 (require_admin → require_read)
- REQ-045: 사용자 가용 데몬 목록 엔드포인트 신규 (GET /v1/daemons)
## Scope Summary
GET /v1/assets 엔드포인트의 인증 의존성을 `require_admin`에서 `require_read`로 완화한다.
신규로 GET /v1/daemons 엔드포인트를 추가해 caller 소유 데몬 목록을 반환한다(require_read).
asset 쓰기 엔드포인트는 require_admin을 유지한다.
## Folder
[bat-019](./bat-019_20260627_assets-read-permission-and-daemon-list/)
@@ -0,0 +1,62 @@
# BAT-019 Planning
- Status: confirmed
- Batch: BAT-019
- Profile: standard
## Included REQ
- REQ-044: GET /v1/assets 권한 완화 (require_admin → require_read)
- REQ-045: 사용자 가용 데몬 목록 엔드포인트 신규 (GET /v1/daemons)
## Delivery Plan
### REQ-044 순서
1. `app/api/v1_assets.py`의 `list_catalog_assets` 의존성을 `require_read`로 교체.
2. 나머지 쓰기 엔드포인트(POST/PATCH/DELETE)는 `require_admin` 유지.
3. 기존 test_asset_catalog.py의 `test_get_v1_assets_user_token_returns_403`,
`test_get_v1_assets_service_token_returns_403` 등 403 기대 테스트를
200/403 신규 기대치로 업데이트.
### REQ-045 순서
1. `app/schemas/v1.py`에 `DaemonResponse`, `DaemonListResponse` 스키마 추가.
2. `app/api/v1_daemons.py` 신규 생성: GET /v1/daemons 라우터.
3. `app/main.py`에 v1_daemons 라우터 include_router 등록.
4. `tests/test_v1_daemons.py` 신규 생성: AC 전체 커버.
### 구현 순서
REQ-044(단순 1줄 변경) → REQ-045(스키마 → 라우터 → 등록 → 테스트) → 테스트 전체 실행
## Dependencies
- Internal:
- `app/core/auth.py`: `require_read` 의존성 (이미 존재)
- `app/domain/models.py`: `Daemon` 도메인 모델 (이미 존재, line 66)
- `app/services/auth_service.py`: `find_candidate_daemons_for_user` (이미 존재, line 161)
- `app/repositories/portfolio_repository.py`: `list_daemons_for_user` (이미 프로토콜 선언됨, line 103)
- External:
- 없음
## Design Gate
- Design Required Before Implementation: yes
- Reason: REQ-045 신규 엔드포인트이므로 경로 컨벤션, 응답 래퍼 스키마, env/service 토큰(user_id=None) 처리, active 필터 여부를 design에서 확정해야 안전하게 구현할 수 있다. REQ-044는 단순 교체지만 같은 batch이므로 design에 함께 기록한다.
## Milestones
1. BAT-019 batch 폴더 및 문서 생성 완료
2. Planning 확정
3. Design 확정 (경로·스키마·env토큰 처리)
4. REQ-044 구현 + 테스트 조정
5. REQ-045 구현 + 신규 테스트
6. pytest 전체 통과
7. Verification 완료
## Risks
- REQ-044: 기존 테스트 중 `require_admin` 기대 403 케이스가 다수 있어 신규 200 기대로 업데이트 필요. 누락 시 기존 테스트 깨짐.
- REQ-045: `user_id=None`(env/service 토큰) 처리를 design에서 명확히 결정해야 구현이 일관됨.
- REQ-045: `AuthService.find_candidate_daemons_for_user`가 `list_daemons_for_user`를 호출하는데, InMemoryRepository 미구현 시 서비스 레이어를 직접 mock해야 함.
@@ -0,0 +1,94 @@
# BAT-019 Verification
- Status: draft
- Batch: BAT-019
## Acceptance Mapping
### REQ-044: GET /v1/assets 권한 완화
- AC: read 토큰 GET /v1/assets → 200
- Evidence: `test_v1_daemons.py::test_assets_read_token_200_regression` PASSED
- Evidence: `test_v1.py::test_v1_assets_returns_asset_list` PASSED (200, total=0)
- Evidence: `test_v1.py::test_v1_assets_env_read_token_returns_200` PASSED
- Evidence: `test_v1.py::test_v1_assets_user_token` PASSED (200, total=0)
- Evidence: `test_asset_catalog.py::test_get_v1_assets_user_token_returns_200` PASSED
- Evidence: `test_asset_catalog.py::test_get_v1_assets_service_token_returns_200` PASSED
- AC: admin GET 200(회귀)
- Evidence: `test_v1_daemons.py::test_assets_admin_get_200_regression` PASSED
- Evidence: `test_v1.py::test_v1_assets_admin_token_default` PASSED
- Evidence: `test_asset_catalog.py::test_get_v1_assets_admin_returns_catalog_response` PASSED
- AC: status·market 필터 유지
- Evidence: `test_v1.py::test_v1_assets_market_filter` PASSED (read 토큰 + market=KRX → 1개 반환)
- Evidence: `test_asset_catalog.py::test_get_v1_assets_market_filter` PASSED
- Evidence: `test_asset_catalog.py::test_get_v1_assets_status_filter_all_includes_inactive` PASSED
- Evidence: `test_asset_catalog.py::test_get_v1_assets_status_filter_inactive_only` PASSED
- Evidence: `test_asset_catalog.py::test_get_v1_assets_status_filter_default_active_only` PASSED
- AC: read 토큰 쓰기 403
- Evidence: `test_v1_daemons.py::test_assets_write_requires_admin_regression` PASSED (POST /v1/assets read → 403)
- Evidence: `test_asset_catalog.py::test_asset_http_non_admin_403` PASSED (POST /v1/assets read → 403)
- AC: 무토큰 401
- Evidence: `test_v1_daemons.py::test_assets_no_token_returns_401_regression` PASSED
- Evidence: `test_v1.py::test_v1_assets_requires_service_token` PASSED
- Evidence: `test_asset_catalog.py::test_get_v1_assets_no_token_returns_401` PASSED
### REQ-045: GET /v1/daemons 신규 엔드포인트
- AC: 사용자A(데몬2) → 자기 2개만
- Evidence: `test_v1_daemons.py::test_daemons_returns_caller_owned_daemons` PASSED
- AC: 필드 4개(id, daemon_slug, display_name, is_active) 포함
- Evidence: `test_v1_daemons.py::test_daemons_response_has_required_fields` PASSED
- AC: 사용자B 데몬 비노출
- Evidence: `test_v1_daemons.py::test_daemons_does_not_expose_other_user_daemons` PASSED (list_daemons_for_user("user-1") 호출 검증)
- AC: 데몬 없으면 빈 목록 200
- Evidence: `test_v1_daemons.py::test_daemons_empty_list_returns_200` PASSED
- AC: 무토큰 401
- Evidence: `test_v1_daemons.py::test_daemons_no_token_returns_401` PASSED
- AC: admin도 caller 스코프
- Evidence: `test_v1_daemons.py::test_daemons_admin_token_with_user_id_returns_caller_daemons` PASSED (admin user_id="admin-1" → admin-1 소유만)
- Evidence: `test_v1_daemons.py::test_daemons_admin_token_with_user_id_none_returns_empty` PASSED (env admin 토큰 user_id=None → 빈 목록)
### Design 결정 AC 검증
- OQ-2 경로: GET /v1/daemons 사용 확인 (라우터 prefix="/v1", path="/daemons")
- OQ-3 스키마: DaemonListResponse{daemons: [...], total: int} 사용 확인
- env토큰 처리: user_id=None → 빈 목록 200 (`test_daemons_admin_token_with_user_id_none_returns_empty` PASSED)
- active 필터: 소유 전체 반환, is_active로 구분 (`test_daemons_includes_inactive_daemons` PASSED)
## Evidence
- Test:
- 전체 실행: `pytest tests/ -v`
- 결과: **472 passed, 0 failed, 11 warnings** (2026-06-27)
- 신규 테스트: `tests/test_v1_daemons.py` 13개 전부 PASSED
- 업데이트된 테스트: `test_v1.py` 5개, `test_asset_catalog.py` 2→3개, `test_daemon_assignment.py` 2개, `test_tags_master.py` 3개
- 기존 v1_assets 회귀: 0 (모두 PASSED)
- 기존 auth 회귀: 0 (모두 PASSED)
```
pytest 실행 원문 마지막 줄:
====================================================== 472 passed, 11 warnings in 24.83s ======================================================
```
- Manual:
- 없음 (DB 연결 없는 환경에서 mock 기반 단위 테스트로 모든 AC 커버)
- Logs:
- 없음
## Result
- Ready for Release: false
- Blocking Issues:
- 없음 (모든 AC 충족, 테스트 전체 통과)
- DB 통합 테스트는 로컬 인프라 필요(비활성 환경) — mock 기반 커버로 대체
- `confirm-batch-verification` 팀장 승인 대기
+1
View File
@@ -26,3 +26,4 @@
| BAT-016 | standard | released | REQ-036, REQ-037, REQ-038 | dcy-006 | [bat-016](./bat-016_20260605_ledger-accounting-consolidation/) |
| BAT-017 | standard | released | REQ-039, REQ-040, REQ-041, REQ-042 | dcy-007 | [bat-017](./bat-017_20260618_trade-history-and-pnl-api/) |
| BAT-018 | batch-lite | release-candidate | REQ-043 | dcy-008 | [bat-018](./bat-018_20260625_holdings-cost-basis-endpoint/) |
| BAT-019 | standard | in-delivery | REQ-044, REQ-045 | dcy-009 | [bat-019](./bat-019_20260627_assets-read-permission-and-daemon-list/) |
@@ -0,0 +1,151 @@
# 0. 문서 상태
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
- 상태: confirmed
- 문서 ID: dcy-009_20260627_user-read-asset-catalog-and-daemons
- 이슈명: 사용자용 read 엔드포인트 2종 — asset 카탈로그 조회 권한 완화(require_read) + 사용자 가용 데몬 목록 신규
- 작성 시각(KST): 2026-06-27 10:07
- 마지막 갱신 시각(KST): 2026-06-27 10:30
- 대체됨: 없음
- 후속 Discovery 참조: 없음
- 교차 서비스 발원(driver): **trex-web 포트폴리오 관리(예정 dcy-007)** — 사용자가 자신의 포트폴리오를 생성/변경할 때 ① asset 리스트에서 종목을 고르고 ② 자신이 사용 가능한 데몬을 선택해야 한다. 두 선택 UI에 필요한 read API가 portfolio-hub에 없다(asset 목록은 admin 전용, 사용자 데몬 목록은 라우트 미노출). 본 Discovery는 그 의존을 portfolio-hub측 변경 단위로 정식화한다. (trex-web 포트폴리오 관리 UI는 별도 trex-web dcy-007.)
- 생성된 REQ 참조: REQ-044, REQ-045
# 1. 계획 상태 요약
- 연계 문서 현황:
- 관련 점수 파일: 없음
- 관련 TODO/메모: 워크스페이스 GitHub 보드 #7 trex-web 항목. 발원 = trex-web 포트폴리오 관리.
- 해석:
- trex-web에서 "각 사용자가 자기 포트폴리오를 관리(생성/변경)"하려면 asset 선택·가용 데몬 선택이 필요한데, portfolio-hub의 해당 read 경로가 사용자에게 막혀 있다.
- 사장님 결정(2026-06-27): **GET /v1/assets를 require_read로 완화**, **사용자 가용 데몬 목록 read 엔드포인트 신규**. 백엔드를 먼저 만들고 trex-web UI를 얹는다.
# 2. 요구사항 판정 결과
- 판정: 신규
- 근거
- asset 카탈로그 조회 권한 완화는 기존 REQ(자산 카탈로그, admin 전용)의 접근 정책 변경 + 신규 사용자 데몬 목록 엔드포인트는 신규 인터페이스다.
- 포트폴리오 관리(생성/변경 시 선택 UI 공급)라는 새 소비 맥락에서 발생한 read 표면 확장이다.
# 3. 문제점의 요약
- 현재 상태
- `GET /v1/assets`(`app/api/v1_assets.py:75` `list_catalog_assets`)는 **`require_admin`** — 일반 사용자가 asset 카탈로그를 읽을 수 없다. (생성/비활성/태그 등 쓰기도 require_admin.)
- **사용자 가용 데몬을 조회하는 HTTP 엔드포인트가 없다.** 도메인 `Daemon`(id·owner_user_id·daemon_slug·display_name·is_active) 존재 + 서비스/리포지토리에 `list_daemons_for_user(user_id)`·`find_candidate_daemons_for_user(user_id)`가 있으나 라우트로 노출되지 않음.
- 포트폴리오 CRUD(`POST/PATCH/DELETE /v1/portfolios`)는 이미 owner-scoped `require_read`로 존재(이번 변경 대상 아님). 포트폴리오는 `managed_by_daemon_id`로 데몬에 바인딩됨(PATCH로 설정 가능).
- 기대 상태
- 일반 사용자(`require_read`)가 **active asset 카탈로그를 조회**할 수 있다(쓰기 권한은 admin 유지).
- 일반 사용자가 **자신이 사용 가능한 데몬 목록**(자기 소유 active 데몬)을 조회할 수 있다 — 포트폴리오의 `managed_by_daemon_id`로 선택할 후보.
- 이해관계자
- 사장님 — 접근 정책(카탈로그 공개 범위)·우선순위 결정
- 팀장(Claude) — Discovery/거버넌스 게이트
- portfolio-hub-dev — delivery(백엔드)
- trex-web(소비자) / 일반 사용자
- 제약사항
- asset 쓰기(생성/비활성/태그 적용)는 **admin 유지**(완화는 읽기에 한정).
- 데몬 목록은 **caller 본인(user_id) 스코프**(타 사용자 데몬 노출 금지).
- 기존 admin asset 관리·서비스 토큰 daemon 바인딩 동작에 회귀 없어야 함.
## 주요 사용자 시나리오
- 사용자가 trex-web에서 포트폴리오를 추가할 때, asset 선택 드롭다운/검색이 `GET /v1/assets`(read)로 active 카탈로그를 불러온다.
- 사용자가 포트폴리오의 관리 데몬을 고를 때, "내 가용 데몬" 목록을 신규 엔드포인트로 불러와 선택한다.
- admin은 기존대로 asset 카탈로그를 생성/수정/비활성(쓰기)한다(권한 불변).
# 4. 이번에 정의할 변경
- 변경 1 — **GET /v1/assets 권한 완화**: `require_admin` → `require_read`. status 필터(기본 active)·market 필터 동작 유지. 쓰기 엔드포인트(POST/PATCH/DELETE assets, 태그)는 `require_admin` 유지.
- 변경 2 — **사용자 가용 데몬 목록 read 엔드포인트 신규**: caller(user_id) 소유 active 데몬을 반환(id·daemon_slug·display_name·is_active). `require_read`, caller 스코프. 기존 서비스 메서드(`list_daemons_for_user`) 활용.
## 영향 범위
- 코드 영향: `app/api/v1_assets.py`(GET 권한 완화), 데몬 목록 라우트 신규(`app/api/`—경로 design), `app/services/auth_service.py`(기존 `list_daemons_for_user` 노출용 wiring), 응답 스키마(DaemonResponse 신규 가능).
- 문서 영향: `docs/srs/`(REQ), `docs/discovery/index.md`. API 계약 문서.
- 운영 영향: asset 카탈로그가 전 인증 사용자에게 read 공개됨(쓰기 불변). 배포 시 계약 변경 고지.
# 5. 요구사항 목록
## 기능 요구사항
- FR-1: `GET /v1/assets`를 `require_read`로 완화해 일반 사용자가 active asset 카탈로그를 조회할 수 있다. 기존 status/market 필터·응답 스키마 유지. asset 쓰기(생성/비활성/태그)는 `require_admin` 유지.
- FR-2: 사용자 가용 데몬 목록 read 엔드포인트를 추가한다 — `require_read`, caller(user_id) 소유 데몬(active) 반환. 응답 필드 id·daemon_slug·display_name·is_active.
## 비기능 요구사항
- NFR-1: 기존 admin asset 관리(쓰기)·서비스 토큰 daemon 바인딩 동작에 회귀 0.
- NFR-2: 데몬 목록은 caller 본인 데몬만 반환(타 사용자 데몬 비노출) — 권한/스코프 정확.
## 범위 경계
- In Scope
- GET /v1/assets require_read 완화
- 사용자 가용 데몬 목록 read 엔드포인트 신규
- Out of Scope
- asset 카탈로그 쓰기(생성/수정/비활성/태그) 권한 변경(admin 유지)
- 데몬 생성/수정/삭제 등 데몬 관리(이번엔 read 목록만)
- 포트폴리오 CRUD 자체(기존 존재) 및 trex-web UI(별도 trex-web dcy-007)
# 6. 리스크/가정 목록
## 리스크
- R-1 (Low): asset 카탈로그를 전 사용자 read 공개 — 민감 정보 노출 우려.
- 완화 방안: 카탈로그는 거래 가능 종목 메타(민감도 낮음). 쓰기는 admin 유지. 필요 시 status=active만 기본 노출.
- R-2 (Low): 데몬 목록 스코프 오류로 타 사용자 데몬 노출.
- 완화 방안: caller user_id 스코프 강제(`list_daemons_for_user(auth.user_id)`), 테스트로 타 사용자 비노출 검증.
## 가정
- A-1: 도메인 `Daemon`(owner_user_id)·`list_daemons_for_user`가 사용자 소유 데몬을 정확히 반환한다.
- A-2: asset 카탈로그(거래 종목 메타)는 전 인증 사용자에게 read 공개해도 무방하다(사장님 승인).
- A-3: 포트폴리오의 `managed_by_daemon_id`에 넣을 후보 = 사용자 소유 데몬(이 목록).
## 오픈 질문
- OQ-1: GET /v1/assets require_read 완화 — 승인 여부.
- 상태: **Answered (2026-06-27, 사장님)** — require_read로 완화 확정(쓰기는 admin 유지). FR-1 반영.
- OQ-2: 데몬 목록 엔드포인트 경로·형태 — 예: `GET /v1/daemons`(caller 스코프) vs `GET /v1/users/me/daemons`. 응답 스키마(DaemonResponse) 형태.
- 상태: Deferred
- 처리 방안: draft-batch-design에서 기존 라우팅 컨벤션에 맞춰 확정.
- 종료 조건: design에서 경로·스키마 확정 시 Answered.
- OQ-3: "가용 데몬" 정의 — 사용자 소유 active 데몬(`list_daemons_for_user`)으로 충분한지, 아니면 미바인딩 후보(`find_candidate_daemons_for_user`) 의미가 필요한지.
- 상태: Deferred
- 처리 방안: design에서 trex-web 포트폴리오 배정 요구(managed_by_daemon_id 후보)에 맞는 의미 1개 확정. 기본안 = 사용자 소유 active 데몬 전체.
- 종료 조건: design에서 의미 확정 시 Answered.
### 오픈 질문 처리 기준
- `Open`/`Answered`/`Deferred`/`Dropped` 표준 처리.
# 7. 초기 성공 기준
- S-1: 일반 사용자(read) 세션으로 `GET /v1/assets` 호출 시 200 + active 카탈로그 반환(admin 아님).
- S-2: 일반 사용자가 asset 쓰기(POST/PATCH/DELETE) 시 여전히 403(admin 전용 유지).
- S-3: 사용자 가용 데몬 목록 엔드포인트가 caller 소유 데몬만 반환(타 사용자 데몬 비노출).
- S-4: 기존 admin asset 관리·토큰 daemon 바인딩 회귀 0.
## 측정 방식 및 데이터 출처
- S-1/S-2: read·admin·다른권한 토큰별 통합 테스트(권한 매트릭스) / 데이터 출처: pytest.
- S-3: 2명 사용자 데몬 fixture로 스코프 테스트 / pytest.
- S-4: 기존 v1_assets·auth 테스트 회귀 / pytest.
# 8. REQ로 넘기기 전 확인 체크
- [x] 범위(In/Out) 확정 — read 2종 In, 쓰기·데몬관리·UI Out (사장님 합의)
- [x] 성공 지표 측정 방식 — 권한 매트릭스·스코프 테스트
- [x] High 리스크 없음(R-1/R-2 Low)
- [ ] OQ-2/OQ-3(엔드포인트 경로·가용데몬 의미) design 인계
- [x] Freeze 담당자(CONFIRMED_BY) — 팀장
# 9. 파일 처리 결과
- 처리 결과: 생성
- 생성 경로: docs/discovery/dcy-009_20260627_user-read-asset-catalog-and-daemons.md
- 참조 문서:
- .github/templates/discovery/discovery.md (템플릿)
- app/api/v1_assets.py(GET /v1/assets — 완화 대상), app/domain/models.py(Daemon), app/services/auth_service.py(list_daemons_for_user)
- 발원: trex-web 포트폴리오 관리(예정 trex-web dcy-007)
# 10. 사용자 결정 필요 항목 요약
## DECIDE
- (해소됨, 2026-06-27) GET /v1/assets require_read 완화 — 승인.
## CONFIRM
- (해소됨, 2026-06-27) 백엔드 read 2종(asset 완화 + 사용자 데몬 목록) 단위 채택 — 쓰기·데몬관리·trex-web UI 제외. 백엔드 우선 진행.
## DATA
- (확인됨) Daemon 필드 id·owner_user_id·daemon_slug·display_name·is_active. asset GET 응답 = AssetCatalogListResponse.
# 11. Discovery Freeze
## 섹션 참조
- 범위: [# 5 > 범위 경계](#5-요구사항-목록) / 리스크·OQ: [# 6](#6-리스크가정-목록) / 성공: [# 7](#7-초기-성공-기준) / 인계 전 확인: [# 8](#8-req로-넘기기-전-확인-체크) / 결정: [# 10](#10-사용자-결정-필요-항목-요약)
## Freeze 플래그
- Handoff Decision: REQ Drafting 진행 가능
- Handoff Rationale: 범위·asset 완화 승인 완료(사장님). OQ-2(엔드포인트 경로)·OQ-3(가용데몬 의미)만 design Deferred — REQ 인계 가능.
- Ready for REQ Drafting: true
- Confirmed By: 팀장 (Claude)
- Confirmed At (KST): 2026-06-27 10:07
- 비고: 본 Discovery는 사장님이 백엔드 변경(asset 완화 + 데몬 목록 read)을 명시 승인했으므로 작성과 동시에 confirm 처리. 상태는 confirm-discovery 게이트에서 confirmed로 전환.
# 12. 작성 가이드
- portfolio-hub .github/templates discovery 기반. trex-web dcy-007(UI)이 이 엔드포인트를 소비한다. design에서 OQ-2/3 확정.
+1
View File
@@ -14,3 +14,4 @@
| dcy-006_20260605_ledger-trade-write-portfolio-state-return | 20260605 | ledger trade write 회계 책임 단일화 — 응답에 portfolio 상태 포함 + portfolio_ledger/ledger_trades 테이블 통합 (issue #8) | [Discovery](./dcy-006_20260605_ledger-trade-write-portfolio-state-return.md) | confirmed |
| dcy-007_20260616_daily-trade-history-and-realized-pnl-query-api | 20260616 | 일일 거래내역 + 실현 PnL 조회 API (운영 리포트/프론트 데이터 소스, issue #11) | [Discovery](./dcy-007_20260616_daily-trade-history-and-realized-pnl-query-api.md) | confirmed |
| dcy-008_20260625_current-holding-wac-cost-basis-read | 20260625 | 현재 보유 종목별 평단(WAC) 조회 API — trex-core 중장기 cold-start 슬롯 평단 seed 의존(교차 발원 trex-core dcy-034 FR-7). GET /v1/portfolios/{id}/holdings/cost-basis | [Discovery](./dcy-008_20260625_current-holding-wac-cost-basis-read.md) | confirmed |
| dcy-009_20260627_user-read-asset-catalog-and-daemons | 20260627 | 사용자용 read 2종 — GET /v1/assets require_read 완화 + 사용자 가용 데몬 목록 신규(교차 발원 trex-web 포트폴리오 관리 dcy-007) | [Discovery](./dcy-009_20260627_user-read-asset-catalog-and-daemons.md) | confirmed |
@@ -0,0 +1,82 @@
# REQ-044: GET /v1/assets 권한 완화 — require_admin → require_read
- Status: Approved
- Approved By: 팀장 (Claude), 2026-06-27 (confirm-req)
- Type: Interface
- Priority: High
- Owner: Justin Ko
## Intent
- `GET /v1/assets`(asset 카탈로그 목록 조회) 엔드포인트의 의존성을 `require_admin`에서 `require_read`로 완화해, 일반 인증 사용자가 active asset 카탈로그를 조회할 수 있도록 한다.
- trex-web 포트폴리오 관리 UI(dcy-007)에서 포트폴리오 생성·변경 시 종목 선택 드롭다운/검색이 이 엔드포인트를 소비한다. 현재 `require_admin`으로 막혀 있어 일반 사용자가 접근 불가한 상태다.
- asset 쓰기(POST 생성, PATCH 수정/비활성, POST/DELETE 태그 적용/해제)는 `require_admin`을 유지한다 — 이번 완화는 읽기 하나에 한정한다.
## Requirement
- 시스템은 `GET /v1/assets` 엔드포인트의 인증 의존성을 `require_admin`에서 `require_read`로 변경해야 한다.
- 기존 동작(status 필터·market 필터·`AssetCatalogListResponse` 응답 스키마)은 변경 없이 유지해야 한다.
- asset 쓰기 엔드포인트(`POST /v1/assets`, `PATCH /v1/assets/{asset_id}`, `PATCH /v1/assets/{asset_id}/deactivate`, `POST /v1/assets/{asset_id}/tags`, `DELETE /v1/assets/{asset_id}/tags/{tag_id}`)는 `require_admin`을 유지해야 한다.
- 유효한 read 권한 이상 토큰(session, read service token, admin)으로 `GET /v1/assets`를 호출하면 HTTP 200과 카탈로그 목록을 반환해야 한다.
- 인증 토큰 없는 요청은 HTTP 401, 권한이 없는 토큰은 기존 동작대로 HTTP 403을 반환해야 한다.
## Acceptance Criteria
1. Given read 권한 세션 토큰(`permission="read"`)을 소지한 사용자, When `GET /v1/assets`를 호출, Then HTTP 200 + `AssetCatalogListResponse` 반환.
2. Given admin 토큰, When `GET /v1/assets`를 호출, Then 기존과 동일하게 HTTP 200 반환(회귀 없음).
3. Given read 권한 사용자, When `GET /v1/assets?status=active`·`?status=inactive`·`?status=all`로 호출, Then status 필터가 적용된 응답 반환(필터 동작 유지).
4. Given read 권한 사용자, When `GET /v1/assets?market=KRX`로 호출, Then market 필터가 적용된 응답 반환(필터 동작 유지).
5. Given read 권한 사용자, When `POST /v1/assets`(asset 생성)를 호출, Then HTTP 403(쓰기는 admin 유지).
6. Given read 권한 사용자, When `PATCH /v1/assets/{asset_id}/deactivate`를 호출, Then HTTP 403.
7. Given read 권한 사용자, When `POST /v1/assets/{asset_id}/tags`를 호출, Then HTTP 403.
8. Given read 권한 사용자, When `DELETE /v1/assets/{asset_id}/tags/{tag_id}`를 호출, Then HTTP 403.
9. Given 인증 토큰 없는 요청, When `GET /v1/assets`를 호출, Then HTTP 401.
## Impacted Area
- Docs: `docs/srs/Interface/req-044_get-v1-assets-require-read-relaxation.md` (신규)
- Modules:
- `app/api/v1_assets.py`: `list_catalog_assets` 함수의 `auth: AuthContext = Depends(require_admin)` → `Depends(require_read)` 변경. import에 `require_read` 추가.
- Tests: 기존 `tests/`의 `GET /v1/assets` 관련 테스트에서 read 토큰으로 200 반환하는 케이스 추가. 쓰기 엔드포인트 403 회귀 확인.
## Notes
- asset 카탈로그(거래 가능 종목 메타 — symbol·display_name·markets·status·태그)는 전 인증 사용자에게 read 공개해도 무방하다고 사장님이 확인(dcy-009 OQ-1 Answered 2026-06-27).
- 코드 변경 범위는 `app/api/v1_assets.py` 단일 파일의 `Depends(require_admin)` → `Depends(require_read)` 한 줄이다. 응답 로직·필터·스키마는 변경 없다.
- `require_read`는 `app/core/auth.py`에 이미 정의(`require_read = permission_dependency(Permission.READ)`)되어 있다.
- NFR-1(기존 admin asset 관리·토큰 daemon 바인딩 회귀 0)은 AC-2, AC-5~8의 쓰기 403 확인으로 흡수한다. 별도 NFR REQ 미생성.
- NFR-2(데몬 목록 caller 스코프)는 REQ-045에서 다룬다.
## Change Log
### CHG-20260627-01
- Date: 2026-06-27
- Author: Justin Ko
#### Change Summary
- 신규 생성 (Proposed) — dcy-009 FR-1 구현을 위한 첫 번째 REQ 초안.
#### Intent
- dcy-009 confirmed Discovery의 FR-1을 REQ로 정규화한다. 코드·테스트 구현의 기준 문서로 사용.
#### Acceptance Criteria Delta
- Added:
- AC-1 ~ AC-9 전체 (신규 초안)
- Updated: 없음
- Removed: 없음
#### Impacted Area Delta
- Docs:
- `docs/srs/Interface/req-044_get-v1-assets-require-read-relaxation.md` 신규 생성
- Modules:
- `app/api/v1_assets.py` (require_admin → require_read 1행)
- Tests:
- read 권한 GET 200 케이스 추가, 쓰기 403 회귀 케이스 확인
#### Delivery Trace Delta
- Batches: 미정 (suggest-batch-reqs 대기)
- Releases: 미정
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 배정
#### Validation Plan
- pytest로 read 토큰 GET /v1/assets 200 확인, admin 쓰기 403 회귀 확인.
@@ -0,0 +1,85 @@
# REQ-045: 사용자 가용 데몬 목록 read 엔드포인트 신설
- Status: Approved
- Approved By: 팀장 (Claude), 2026-06-27 (confirm-req)
- Type: Interface
- Priority: High
- Owner: Justin Ko
## Intent
- caller(인증 사용자)가 자신이 소유한 데몬 목록을 조회할 수 있는 HTTP 엔드포인트를 신설한다.
- trex-web 포트폴리오 관리 UI(dcy-007)에서 포트폴리오를 생성하거나 관리 데몬(`managed_by_daemon_id`)을 지정할 때, 사용자가 선택 가능한 데몬 후보를 이 엔드포인트로 불러온다.
- 현재 도메인 `Daemon` 모델과 `list_daemons_for_user(user_id)` 서비스/리포지토리가 존재하나 HTTP 라우트로 노출되지 않는 상태다. 이 REQ는 그 노출 경로를 추가한다.
## Requirement
- 시스템은 caller(인증 사용자) 소유 데몬 목록을 반환하는 GET 엔드포인트를 `require_read` 권한으로 제공해야 한다.
- 응답은 caller(`auth.user_id`) 소유 데몬만 포함해야 한다 — 타 사용자 데몬은 절대 반환하지 않는다.
- 각 응답 항목은 다음 필드를 포함해야 한다: `id`(str), `daemon_slug`(str), `display_name`(str | None), `is_active`(bool).
- 엔드포인트 경로와 응답 스키마(`DaemonResponse`) 구체 형태는 draft-batch-design에서 기존 라우팅 컨벤션에 맞춰 확정한다(OQ-2 Deferred).
- 인증 토큰 없는 요청은 HTTP 401을 반환해야 한다.
- 해당 사용자에게 데몬이 없는 경우 빈 목록(`[]`)으로 HTTP 200을 반환해야 한다(404 아님).
- 기존 서비스 메서드 `find_candidate_daemons_for_user(user_id)`(`AuthService`)를 활용한다. 이 메서드는 내부적으로 `repository.list_daemons_for_user(user_id)`를 호출한다.
## Acceptance Criteria
1. Given read 권한 토큰을 소지한 사용자 A(데몬 2개 소유), When 데몬 목록 엔드포인트를 호출, Then HTTP 200 + 사용자 A의 데몬 2개만 포함된 목록 반환.
2. Given read 권한 토큰을 소지한 사용자 A, When 데몬 목록 엔드포인트를 호출, Then 응답 항목이 `id`, `daemon_slug`, `display_name`, `is_active` 필드를 모두 포함.
3. Given read 권한 토큰을 소지한 사용자 A, When 데몬 목록 엔드포인트를 호출, Then 사용자 B의 데몬이 응답에 포함되지 않음(타 사용자 비노출).
4. Given read 권한 사용자이고 자신에게 데몬이 없을 때, When 데몬 목록 엔드포인트를 호출, Then HTTP 200 + 빈 목록(`items: []`).
5. Given 인증 토큰 없는 요청, When 데몬 목록 엔드포인트를 호출, Then HTTP 401.
6. Given admin 토큰을 소지한 사용자, When 데몬 목록 엔드포인트를 호출, Then HTTP 200 + 해당 admin 사용자 소유 데몬만 반환(admin 권한이어도 caller 스코프 적용).
7. Given read 권한 토큰으로 기존 `GET /v1/assets`(REQ-044)를 호출, Then 회귀 없음(데몬 라우터 추가가 기존 assets 라우터에 영향 없음).
## Impacted Area
- Docs: `docs/srs/Interface/req-045_user-daemon-list-endpoint.md` (신규)
- Modules:
- 신규 라우터 파일 또는 기존 라우터에 엔드포인트 추가(경로·파일 위치는 design 확정 — OQ-2).
- `app/schemas/v1.py` 또는 별도 스키마 파일: `DaemonResponse` Pydantic 모델 신규 정의.
- `app/services/auth_service.py`: `find_candidate_daemons_for_user` 기존 활용(변경 없거나 최소).
- `app/main.py` 또는 라우터 등록: 신규 라우터 include.
- Tests: 2사용자 fixture로 caller 스코프 격리 테스트(AC-3). 빈 목록·401 케이스. 기존 assets/auth 회귀.
## Notes
- OQ-2(엔드포인트 경로·응답 스키마 형태): design 단계에서 기존 라우팅 컨벤션(`/v1/daemons` vs `/v1/users/me/daemons` 등)을 분석해 확정. 이 REQ에서는 경로를 확정하지 않으며 design Deferred. confirm-req 시 blocker 아님 — design 단계 위임 가능.
- OQ-3("가용 데몬" 의미): 현재 기본안은 사용자 소유 데몬 전체(`find_candidate_daemons_for_user`). active 여부 필터링이 필요한지(소유 active 전체 vs 미바인딩 후보) design에서 확정. 이 REQ에서는 "caller 소유 데몬"으로만 명시하며 active 필터는 design 위임.
- env 기반 token(`token_source="env"`)은 `user_id`가 None이다. 이 경우 `auth.user_id`가 없어 caller 스코프 적용이 불가 — design에서 env 토큰의 처리 방침(거부 vs 전체 반환)을 확정할 것. 기본안: DB 토큰만 허용(user_id 없으면 HTTP 403).
- NFR-1(기존 admin asset 관리·토큰 daemon 바인딩 회귀 0)은 AC-7 회귀 확인으로 흡수한다.
- NFR-2(caller 스코프 정확)는 AC-1/3/6으로 흡수한다.
## Change Log
### CHG-20260627-01
- Date: 2026-06-27
- Author: Justin Ko
#### Change Summary
- 신규 생성 (Proposed) — dcy-009 FR-2 구현을 위한 REQ 초안.
#### Intent
- dcy-009 confirmed Discovery의 FR-2를 REQ로 정규화한다. 코드·테스트 구현의 기준 문서로 사용.
#### Acceptance Criteria Delta
- Added:
- AC-1 ~ AC-7 전체 (신규 초안)
- Updated: 없음
- Removed: 없음
#### Impacted Area Delta
- Docs:
- `docs/srs/Interface/req-045_user-daemon-list-endpoint.md` 신규 생성
- Modules:
- 신규 라우터 파일(경로 design 확정 후), `DaemonResponse` 스키마 신규, `app/main.py` 라우터 등록
- Tests:
- 2사용자 스코프 격리 테스트, 빈 목록·401 케이스 신규
#### Delivery Trace Delta
- Batches: 미정 (suggest-batch-reqs 대기)
- Releases: 미정
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 후 batch 배정. OQ-2/OQ-3는 design 단계에서 해소.
#### Validation Plan
- pytest로 AC-1~7 각 케이스 확인. 특히 AC-3(2사용자 fixture 사용해 타 사용자 데몬 비노출) 강제 검증.
+4 -1
View File
@@ -74,7 +74,7 @@
## Next Requirement ID
- Current: `REQ-044`
- Current: `REQ-046`
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
## Requirement Register
@@ -124,9 +124,12 @@
| REQ-041 | WAC 기반 cost_basis 산출 및 실현 PnL 계산 로직 (service/domain 계층 격리) | Integration | Implemented | High | Justin Ko | [req-041](./Integration/req-041_wac-cost-basis-pnl-calculation.md) |
| REQ-042 | 체결 내역/실현 PnL API 테스트 커버리지 (WAC 시나리오 기댓값 대조, 날짜 필터, 422) | Testing | Implemented | High | Justin Ko | [req-042](./Testing/req-042_trade-pnl-api-test-coverage.md) |
| REQ-043 | GET /v1/portfolios/{id}/holdings/cost-basis 현재 보유 평단(WAC) read 엔드포인트 신설 | Interface | Implemented | High | Justin Ko | [req-043](./Interface/req-043_holdings-cost-basis-read-endpoint.md) |
| REQ-044 | GET /v1/assets 권한 완화 — require_admin → require_read | Interface | Approved | High | Justin Ko | [req-044](./Interface/req-044_get-v1-assets-require-read-relaxation.md) |
| REQ-045 | 사용자 가용 데몬 목록 read 엔드포인트 신설 | Interface | Approved | High | Justin Ko | [req-045](./Interface/req-045_user-daemon-list-endpoint.md) |
## Recent Change Log Summary
- 2026-06-27: REQ-044/045 신규 (Proposed) — dcy-009 FR-1(GET /v1/assets require_read 완화)·FR-2(사용자 가용 데몬 목록 엔드포인트 신설) REQ 초안 작성. 엔드포인트 경로·스키마(OQ-2) 및 가용 데몬 의미(OQ-3)는 design Deferred.
- 2026-06-26: REQ-041 WAC 공식 change-req(CHG-20260626-01) — 매수 수수료 cost basis 편입(사장님 결정). 신 공식: `running_wac = (running_wac*qty + price*qty + buy_fee) / new_total_qty`. `calculate_realized_pnl`과 `calculate_current_wac` 양 함수 적용. AC-9/AC-10 추가. 신규 테스트 9개 포함 456 passed / 0 failed. REQ-040/REQ-043 Notes 참조 갱신.
- 2026-06-26: REQ-043 `Approved` → `Implemented` — BAT-018 PR #14 squash-merge(d93c0bc) 팀장 QC PASS 후 origin/main 머지 완료. AC-1~11 전부 PASS(447 passed / 0 failed). 배포 별도(release-candidate).
- 2026-06-25: REQ-043 `Proposed` → `Approved` (confirm-req 팀장 승인) — dcy-008 근거 Discovery 양방향 링크 확정. OQ-1(service token 소유권 blocker) 해소: cold-start는 `managed_by_daemon_id=daemon` 포트폴리오만 조회하므로 null 케이스 blocker 아님, daemon scope 소유권 정책(REQ-029 선례) 유지 확정. OQ-2(domain 함수) 구현 단계 재량 위임. AC-1~11 유지.
+20 -5
View File
@@ -1023,25 +1023,40 @@ def test_get_v1_assets_market_filter() -> None:
assert items[0]["symbol"] == "005930"
# REQ-010 AC-12: non-admin → HTTP 403
def test_get_v1_assets_user_token_returns_403() -> None:
# REQ-044: GET /v1/assets is now require_read — user/service tokens return 200
def test_get_v1_assets_user_token_returns_200() -> None:
"""REQ-044: read user token accepted by GET /v1/assets."""
client, _, patches = _make_catalog_get_client(_USER_KW, [], {})
try:
resp = client.get("/v1/assets", headers={"Authorization": "Bearer user"})
finally:
for p in patches:
p.stop()
assert resp.status_code == 403
assert resp.status_code == 200
assert resp.json()["total"] == 0
def test_get_v1_assets_service_token_returns_403() -> None:
def test_get_v1_assets_service_token_returns_200() -> None:
"""REQ-044: trade-write service token accepted by GET /v1/assets (trade-write >= read)."""
client, _, patches = _make_catalog_get_client(_SERVICE_KW, [], {})
try:
resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc"})
finally:
for p in patches:
p.stop()
assert resp.status_code == 403
assert resp.status_code == 200
assert resp.json()["total"] == 0
def test_get_v1_assets_no_token_returns_401() -> None:
"""GET /v1/assets without token returns 401 (require_read still gates auth)."""
client, _, patches = _make_catalog_get_client(_ADMIN_KW, [], {})
try:
resp = client.get("/v1/assets") # no Authorization header
finally:
for p in patches:
p.stop()
assert resp.status_code == 401
# ---------------------------------------------------------------------------
+15 -7
View File
@@ -64,30 +64,38 @@ def _ledger_trade_payload() -> dict:
# (daemon-scoped portfolio list moved to GET /v1/portfolios)
# ---------------------------------------------------------------------------
def test_assets_403_when_token_has_no_daemon_id(monkeypatch) -> None:
"""GET /v1/assets requires admin — service token with no daemon_id gets 403 (Insufficient permission)."""
def test_assets_200_when_token_has_no_daemon_id(monkeypatch) -> None:
"""REQ-044: GET /v1/assets now requires only read — trade-write service token (no daemon_id) gets 200."""
client = _app_with_db(monkeypatch)
principal = _principal(daemon_id=None)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_assets.return_value = []
mock_repo.get_tags_by_ids.return_value = {}
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
resp = client.get("/v1/assets", headers=_svc_header())
assert resp.status_code == 403
assert resp.status_code == 200
assert resp.json()["total"] == 0
def test_assets_200_with_daemon_scoped_filter(monkeypatch) -> None:
"""GET /v1/assets requires admin — service token with daemon_id is also rejected."""
"""REQ-044: GET /v1/assets now requires only read — trade-write service token with daemon_id also gets 200."""
client = _app_with_db(monkeypatch)
principal = _principal(daemon_id="d-1")
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_assets.return_value = []
mock_repo.get_tags_by_ids.return_value = {}
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
resp = client.get("/v1/assets", headers=_svc_header())
assert resp.status_code == 403
assert resp.status_code == 200
assert resp.json()["total"] == 0
# ---------------------------------------------------------------------------
+53 -9
View File
@@ -320,54 +320,98 @@ def test_asset_item_empty_tag_ids_returns_none() -> None:
def test_v1_assets_active_tag_not_archived(monkeypatch) -> None:
"""GET /v1/assets는 admin 전용 — user token은 403 반환 (archived 표시는 GET /v1/portfolios로 이관)."""
"""REQ-044: GET /v1/assets now accepts read token — active tag returns archived=False."""
from app.domain.models import Asset, Tag
import datetime as _dt
settings = _make_v1_settings(monkeypatch)
principal = TokenPrincipal(
token_id="t1", user_id=_USER_ID, permission="read",
token_type="user", runtime_id="rt-1",
)
asset = Asset(
asset_id="a-1", symbol="005930", markets=["KRX"],
status="active", tag_ids=[_TEST_TAG_ID_1],
created_at=_dt.datetime.now(_dt.timezone.utc),
updated_at=_dt.datetime.now(_dt.timezone.utc),
)
active_tag = Tag(tag_id=_TEST_TAG_ID_1, name="kospi200", created_by_user_id="admin", is_active=True)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_assets.return_value = [asset]
mock_repo.get_tags_by_ids.return_value = {_TEST_TAG_ID_1: active_tag}
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_v1_client(settings)
resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 403
assert resp.status_code == 200
item = resp.json()["items"][0]
assert item["tag_ids"] == [{"tag_id": _TEST_TAG_ID_1, "archived": False}]
def test_v1_assets_inactive_tag_archived_true(monkeypatch) -> None:
"""GET /v1/assets는 admin 전용 — user token은 403 반환."""
"""REQ-044: GET /v1/assets now accepts read token — inactive tag returns archived=True."""
from app.domain.models import Asset, Tag
import datetime as _dt
settings = _make_v1_settings(monkeypatch)
principal = TokenPrincipal(
token_id="t1", user_id=_USER_ID, permission="read",
token_type="user", runtime_id="rt-1",
)
asset = Asset(
asset_id="a-1", symbol="005930", markets=["KRX"],
status="active", tag_ids=[_TEST_TAG_ID_1, _TEST_TAG_ID_2],
created_at=_dt.datetime.now(_dt.timezone.utc),
updated_at=_dt.datetime.now(_dt.timezone.utc),
)
active_tag = Tag(tag_id=_TEST_TAG_ID_1, name="kospi200", created_by_user_id="admin", is_active=True)
inactive_tag = Tag(tag_id=_TEST_TAG_ID_2, name="legacy", created_by_user_id="admin", is_active=False)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_assets.return_value = [asset]
mock_repo.get_tags_by_ids.return_value = {_TEST_TAG_ID_1: active_tag, _TEST_TAG_ID_2: inactive_tag}
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_v1_client(settings)
resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 403
assert resp.status_code == 200
item = resp.json()["items"][0]
by_id = {t["tag_id"]: t for t in item["tag_ids"]}
assert by_id[_TEST_TAG_ID_1]["archived"] is False
assert by_id[_TEST_TAG_ID_2]["archived"] is True
def test_v1_assets_empty_tag_ids_returns_none(monkeypatch) -> None:
"""GET /v1/assets는 admin 전용 — user token은 403 반환."""
"""REQ-044: GET /v1/assets now accepts read token — asset with no tags returns empty tag_ids."""
from app.domain.models import Asset
import datetime as _dt
settings = _make_v1_settings(monkeypatch)
principal = TokenPrincipal(
token_id="t1", user_id=_USER_ID, permission="read",
token_type="user", runtime_id="rt-1",
)
asset = Asset(
asset_id="a-1", symbol="005930", markets=["KRX"],
status="active", tag_ids=[],
created_at=_dt.datetime.now(_dt.timezone.utc),
updated_at=_dt.datetime.now(_dt.timezone.utc),
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_assets.return_value = [asset]
mock_repo.get_tags_by_ids.return_value = {}
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_v1_client(settings)
resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 403
assert resp.status_code == 200
item = resp.json()["items"][0]
assert item["tag_ids"] == []
# ---------------------------------------------------------------------------
+56 -16
View File
@@ -610,18 +610,26 @@ def test_v1_assets_requires_service_token(monkeypatch) -> None:
assert resp.status_code == 401
def test_v1_assets_rejects_env_token() -> None:
def test_v1_assets_env_read_token_returns_200(monkeypatch) -> None:
"""REQ-044: env read token is now accepted (require_read) — list_catalog_assets."""
from app.core.config import get_settings
from app.domain.models import Asset
import datetime as _dt
settings = _make_settings(db_enabled=False, read_tokens_raw="env-token")
mock_repo = MagicMock()
mock_repo.list_assets.return_value = []
mock_repo.get_tags_by_ids.return_value = {}
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
client = TestClient(app)
resp = client.get("/v1/assets", headers=_service_token_header("env-token"))
assert resp.status_code == 403
with patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
resp = client.get("/v1/assets", headers=_service_token_header("env-token"))
assert resp.status_code == 200
assert resp.json()["total"] == 0
def test_v1_assets_returns_asset_list(monkeypatch) -> None:
"""GET /v1/assets requires admin — service token is rejected."""
"""REQ-044: GET /v1/assets now accepts read service token — returns 200 with catalog list."""
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
@@ -629,12 +637,16 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_assets.return_value = []
mock_repo.get_tags_by_ids.return_value = {}
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app()
resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 403
assert resp.status_code == 200
assert resp.json()["total"] == 0
# ---------------------------------------------------------------------------
@@ -642,7 +654,7 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
# ---------------------------------------------------------------------------
def test_v1_assets_user_token(monkeypatch) -> None:
"""User token is rejected — admin catalog endpoint requires admin permission."""
"""REQ-044: user read token is now accepted (require_read) — returns 200."""
settings = _make_app_db(monkeypatch)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
@@ -650,12 +662,16 @@ def test_v1_assets_user_token(monkeypatch) -> None:
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_assets.return_value = []
mock_repo.get_tags_by_ids.return_value = {}
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 403
assert resp.status_code == 200
assert resp.json()["total"] == 0
def test_v1_assets_admin_token_default(monkeypatch) -> None:
@@ -716,24 +732,44 @@ def test_v1_assets_admin_token_user_id_param(monkeypatch) -> None:
def test_v1_assets_market_filter(monkeypatch) -> None:
"""Service token is rejected by admin catalog endpoint — REQ-010 AC-12."""
"""REQ-044: read service token with market filter now returns 200 (require_read)."""
from app.domain.models import Asset
import datetime as _dt
settings = _make_app_db(monkeypatch)
krx_asset = Asset(
asset_id="a-krx", symbol="005930", markets=["KRX"],
display_name="Samsung", status="active",
created_at=_dt.datetime.now(_dt.timezone.utc),
updated_at=_dt.datetime.now(_dt.timezone.utc),
)
nasdaq_asset = Asset(
asset_id="a-nasdaq", symbol="AAPL", markets=["NASDAQ"],
display_name="Apple", status="active",
created_at=_dt.datetime.now(_dt.timezone.utc),
updated_at=_dt.datetime.now(_dt.timezone.utc),
)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1", daemon_id="d-1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_assets.return_value = [krx_asset, nasdaq_asset]
mock_repo.get_tags_by_ids.return_value = {}
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets?market=KRX", headers=_service_token_header())
assert resp.status_code == 403
assert resp.status_code == 200
items = resp.json()["items"]
assert len(items) == 1
assert items[0]["symbol"] == "005930"
def test_v1_assets_asset_id_is_uuid(monkeypatch) -> None:
"""Service token is rejected by admin catalog endpoint."""
def test_v1_assets_read_token_200(monkeypatch) -> None:
"""REQ-044: read service token accepted by GET /v1/assets (require_read)."""
settings = _make_app_db(monkeypatch)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
@@ -741,12 +777,16 @@ def test_v1_assets_asset_id_is_uuid(monkeypatch) -> None:
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_assets.return_value = []
mock_repo.get_tags_by_ids.return_value = {}
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 403
assert resp.status_code == 200
assert resp.json()["total"] == 0
# ---------------------------------------------------------------------------
+349
View File
@@ -0,0 +1,349 @@
"""Tests for BAT-019: GET /v1/daemons — caller-scoped daemon list (REQ-045)."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.core.config import Settings, get_settings
from app.domain.models import Daemon, TokenPrincipal
from app.main import create_app
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
def _make_settings(**kwargs) -> Settings:
return Settings(
allowed_origins_raw="http://localhost:3000",
**kwargs,
)
def _make_app(settings: Settings) -> TestClient:
get_settings.cache_clear()
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
return TestClient(app)
def _auth_header(token: str = "test-token") -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _make_daemon(
daemon_id: str = "d-1",
owner_user_id: str = "user-1",
daemon_slug: str = "trex-orb-v1",
display_name: str | None = "ORB v1",
is_active: bool = True,
) -> Daemon:
return Daemon(
id=daemon_id,
owner_user_id=owner_user_id,
daemon_slug=daemon_slug,
display_name=display_name,
is_active=is_active,
)
def _make_mock_repo_with_principal(principal: TokenPrincipal, daemons: list[Daemon]) -> MagicMock:
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_daemons_for_user.return_value = daemons
return mock_repo
_SETTINGS_DB = _make_settings(db_enabled=True, credential_encryption_key=_TEST_ENC_KEY)
# ---------------------------------------------------------------------------
# REQ-045 AC: 사용자A(데몬2) → 자기 2개만
# ---------------------------------------------------------------------------
def test_daemons_returns_caller_owned_daemons() -> None:
"""REQ-045 AC: 사용자A 소유 2개 데몬 → 2개 반환."""
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1",
)
daemons = [
_make_daemon("d-1", "user-1", "trex-orb-v1", "ORB v1", is_active=True),
_make_daemon("d-2", "user-1", "trex-tsl-v1", "TSL v1", is_active=True),
]
mock_repo = _make_mock_repo_with_principal(principal, daemons)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_SETTINGS_DB)
resp = client.get("/v1/daemons", headers=_auth_header())
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 2
assert len(body["daemons"]) == 2
slugs = {d["daemon_slug"] for d in body["daemons"]}
assert slugs == {"trex-orb-v1", "trex-tsl-v1"}
# ---------------------------------------------------------------------------
# REQ-045 AC: 필드 4개 포함
# ---------------------------------------------------------------------------
def test_daemons_response_has_required_fields() -> None:
"""REQ-045 AC: id, daemon_slug, display_name, is_active 필드 포함."""
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1",
)
daemon = _make_daemon("d-1", "user-1", "trex-orb-v1", "ORB v1", is_active=True)
mock_repo = _make_mock_repo_with_principal(principal, [daemon])
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_SETTINGS_DB)
resp = client.get("/v1/daemons", headers=_auth_header())
assert resp.status_code == 200
item = resp.json()["daemons"][0]
assert item["id"] == "d-1"
assert item["daemon_slug"] == "trex-orb-v1"
assert item["display_name"] == "ORB v1"
assert item["is_active"] is True
# ---------------------------------------------------------------------------
# REQ-045 AC: display_name None 허용
# ---------------------------------------------------------------------------
def test_daemons_display_name_can_be_none() -> None:
"""REQ-045: display_name=None인 데몬도 정상 반환."""
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1",
)
daemon = _make_daemon("d-3", "user-1", "trex-custom", None, is_active=True)
mock_repo = _make_mock_repo_with_principal(principal, [daemon])
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_SETTINGS_DB)
resp = client.get("/v1/daemons", headers=_auth_header())
assert resp.status_code == 200
item = resp.json()["daemons"][0]
assert item["display_name"] is None
# ---------------------------------------------------------------------------
# REQ-045 AC: 타사용자 데몬 비노출 (사용자B 데몬은 user-2 소유)
# ---------------------------------------------------------------------------
def test_daemons_does_not_expose_other_user_daemons() -> None:
"""REQ-045 AC: 사용자B 데몬은 사용자A 조회 시 반환되지 않는다."""
principal_a = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1",
)
# list_daemons_for_user("user-1")은 user-1 소유만 반환 (repository 레벨 격리)
daemons_for_user_a = [
_make_daemon("d-1", "user-1", "trex-orb-v1", "ORB v1", is_active=True),
]
mock_repo = _make_mock_repo_with_principal(principal_a, daemons_for_user_a)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_SETTINGS_DB)
resp = client.get("/v1/daemons", headers=_auth_header())
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1
# user-2 소유 데몬은 없음
owner_ids = {d.get("owner_user_id") for d in body["daemons"]}
# DaemonResponse는 owner_user_id 미포함이므로, repository 호출 인수 검증
mock_repo.list_daemons_for_user.assert_called_once_with("user-1")
# ---------------------------------------------------------------------------
# REQ-045 AC: 데몬 없으면 빈 목록 200
# ---------------------------------------------------------------------------
def test_daemons_empty_list_returns_200() -> None:
"""REQ-045 AC: 데몬 없는 사용자 → 빈 목록 200."""
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1",
)
mock_repo = _make_mock_repo_with_principal(principal, [])
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_SETTINGS_DB)
resp = client.get("/v1/daemons", headers=_auth_header())
assert resp.status_code == 200
body = resp.json()
assert body["daemons"] == []
assert body["total"] == 0
# ---------------------------------------------------------------------------
# REQ-045 AC: 무토큰 → 401
# ---------------------------------------------------------------------------
def test_daemons_no_token_returns_401() -> None:
"""REQ-045 AC: 무토큰 → 401."""
settings = _make_settings(db_enabled=False)
client = _make_app(settings)
resp = client.get("/v1/daemons")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# REQ-045 AC: admin도 caller 스코프 (admin user_id=None → 빈 목록)
# ---------------------------------------------------------------------------
def test_daemons_admin_token_with_user_id_none_returns_empty() -> None:
"""REQ-045: env admin 토큰(user_id=None)은 빈 목록 200 반환."""
settings = _make_settings(db_enabled=False, admin_tokens_raw="admin-env-token")
mock_repo = MagicMock()
mock_repo.list_daemons_for_user.return_value = []
with patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(settings)
resp = client.get("/v1/daemons", headers=_auth_header("admin-env-token"))
assert resp.status_code == 200
body = resp.json()
assert body["daemons"] == []
assert body["total"] == 0
# user_id=None이므로 list_daemons_for_user는 호출되지 않아야 함
mock_repo.list_daemons_for_user.assert_not_called()
def test_daemons_admin_token_with_user_id_returns_caller_daemons() -> None:
"""REQ-045: admin DB 토큰(user_id='admin-1')은 admin-1 소유 데몬만 반환."""
principal = TokenPrincipal(
token_id="t-admin", user_id="admin-1", permission="admin",
token_type="admin", runtime_id="rt-admin",
)
daemons = [
_make_daemon("d-admin-1", "admin-1", "admin-bot", "Admin Bot", is_active=True),
]
mock_repo = _make_mock_repo_with_principal(principal, daemons)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_SETTINGS_DB)
resp = client.get("/v1/daemons", headers=_auth_header())
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1
assert body["daemons"][0]["id"] == "d-admin-1"
mock_repo.list_daemons_for_user.assert_called_once_with("admin-1")
# ---------------------------------------------------------------------------
# REQ-045: is_active=False인 데몬도 반환 (소유 데몬 전체)
# ---------------------------------------------------------------------------
def test_daemons_includes_inactive_daemons() -> None:
"""REQ-045: inactive 데몬도 is_active=False로 반환됨."""
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1",
)
daemons = [
_make_daemon("d-1", "user-1", "trex-orb-v1", "ORB v1", is_active=True),
_make_daemon("d-2", "user-1", "old-bot", "Old Bot", is_active=False),
]
mock_repo = _make_mock_repo_with_principal(principal, daemons)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_SETTINGS_DB)
resp = client.get("/v1/daemons", headers=_auth_header())
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 2
active_flags = {d["daemon_slug"]: d["is_active"] for d in body["daemons"]}
assert active_flags["trex-orb-v1"] is True
assert active_flags["old-bot"] is False
# ---------------------------------------------------------------------------
# REQ-044 회귀: GET /v1/assets read 토큰 → 200, 쓰기는 admin 필요 → 403
# ---------------------------------------------------------------------------
def test_assets_read_token_200_regression() -> None:
"""REQ-044 회귀: read 토큰으로 GET /v1/assets → 200."""
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_assets.return_value = []
mock_repo.get_tags_by_ids.return_value = {}
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_SETTINGS_DB)
resp = client.get("/v1/assets", headers=_auth_header())
assert resp.status_code == 200
assert resp.json()["total"] == 0
def test_assets_write_requires_admin_regression() -> None:
"""REQ-044 회귀: read 토큰으로 POST /v1/assets → 403 (쓰기는 require_admin 유지)."""
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1",
)
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):
client = _make_app(_SETTINGS_DB)
resp = client.post(
"/v1/assets",
json={"symbol": "005930", "markets": ["KRX"]},
headers=_auth_header(),
)
assert resp.status_code == 403
def test_assets_no_token_returns_401_regression() -> None:
"""REQ-044 회귀: 무토큰 GET /v1/assets → 401."""
settings = _make_settings(db_enabled=False)
client = _make_app(settings)
resp = client.get("/v1/assets")
assert resp.status_code == 401
def test_assets_admin_get_200_regression() -> None:
"""REQ-044 회귀: admin 토큰 GET /v1/assets → 200 (기존 동작 유지)."""
principal = TokenPrincipal(
token_id="t-admin", user_id="admin-1", permission="admin",
token_type="admin", runtime_id="rt-1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_assets.return_value = []
mock_repo.get_tags_by_ids.return_value = {}
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_SETTINGS_DB)
resp = client.get("/v1/assets", headers=_auth_header())
assert resp.status_code == 200