feat(bat-019): REQ-044 GET /v1/assets require_read + REQ-045 GET /v1/daemons caller-scope

REQ-044: list_catalog_assets 의존성을 require_admin → require_read로 완화.
asset 쓰기(POST/PATCH/DELETE)는 require_admin 유지.

REQ-045: GET /v1/daemons 신규 엔드포인트 — caller 소유 데몬만 반환(require_read).
user_id=None(env 토큰)은 빈 목록 200. DaemonResponse/DaemonListResponse 스키마 추가.

기존 테스트(test_v1.py, test_asset_catalog.py, test_daemon_assignment.py, test_tags_master.py)
REQ-044 권한 완화에 맞게 업데이트. test_v1_daemons.py 신규 추가(13 케이스).

pytest 472 passed, 0 failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
2026-06-27 10:36:04 +09:00
co-authored by Claude Sonnet 4.6
parent d43ff430c2
commit c5288a8c42
13 changed files with 769 additions and 39 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해야 함.
+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