# BAT-001 Verification - Status: draft - Batch: BAT-001 ## Acceptance Mapping ### REQ-004: GET /health 응답 스키마 확장 | # | Acceptance Criterion | Evidence | Result | |---|---|---|---| | AC1 | status/service/version/checked_at 필드 모두 존재 | `tests/test_v1.py::test_health_returns_full_response` — 4개 필드 전체 assert | PASS | | AC2 | status 허용값 ok/degraded/failed | test에서 "ok" 값만 검증. "degraded"/"failed" 경로 코드 미구현(Notes에 이번 반복 Out of Scope 명시) — 스키마에 허용값으로만 선언 | PARTIAL (OOS 인정) | | AC3 | checked_at ISO 8601 형식 | `test_v1.py::test_health_returns_full_response` — `"+09:00" in body["checked_at"]` assert | PASS | | AC4 | service 고정값 "portfolio-hub" | `test_v1.py` — `body["service"] == "portfolio-hub"` | PASS | | AC5 | 비인증 접근 200 | `test_v1.py::test_health_returns_full_response` — 인증 헤더 없이 GET /health 호출, 200 응답 | PASS | **REQ-004 판정: PASS** (AC2는 REQ Notes에서 이번 반복 OOS로 명시 — 릴리스 블로커 아님) --- ### REQ-005: service-token 발급 시 runtime_id 동시 생성 | # | Acceptance Criterion | Evidence | Result | |---|---|---|---| | AC1 | CLI 출력에 token + runtime_id | `app/cli/commands/service_tokens.py` — `ServiceTokenIssueCliResponse(runtime_id=issued.auth_token.runtime_id, ...)` 구현, `tests/test_cli.py::test_cli_service_token_issue_supports_json` PASS (FakeRepo에 runtime_id 반영) | PASS | | AC2 | API 엔드포인트 응답에 token + runtime_id | service-token 발급 API 엔드포인트 미구현 (CLI 전용으로 구현). `app/api/auth_routes.py` 변경 없음. REQ-005 AC2는 "POST /auth/tokens 또는 동등 경로"를 명시함 | **BLOCKER** | | AC3 | GET /v1/user_info 응답 runtime_id 일치 | `tests/test_v1.py::test_v1_user_info_returns_user` — `body["runtime_id"] == "rt-abc"` | PASS | | AC4 | 두 번 발급 시 다른 runtime_id | `auth_service.py`에서 매 발급마다 `str(uuid.uuid4())` 생성 — 코드 검토 통과. 전용 테스트 없음 | PARTIAL | | AC5 | auth_tokens 테이블 runtime_id 저장 | `postgres.py::create_auth_token` INSERT에 runtime_id 포함, `_SELECT_AUTH_TOKEN`에 runtime_id 컬럼 추가 — 코드 검토 통과. DB 환경 실행 미확인 | PARTIAL (DB 미확인) | **REQ-005 판정: BLOCKED** — AC2 서비스 토큰 발급 API 엔드포인트 누락 --- ### REQ-006: GET /v1/assets 엔드포인트 | # | Acceptance Criterion | Evidence | Result | |---|---|---|---| | AC1 | service-token 인증 → 200 + portfolio 목록 | `tests/test_v1.py::test_v1_assets_returns_asset_list` PASS | PASS | | AC2 | ?status=active 쿼리 파라미터 필터 | `v1_assets.py`에 `status` 쿼리 파라미터 미구현. `list_portfolios_with_strategies(include_disabled=False)`만 호출 — active 필터 없음 | **BLOCKER** | | AC3 | 응답 필드: asset_id, symbol, market, status, cash, quantity, broker_type, strategy_id, strategy_params, updated_at | `AssetItem` 스키마에 `cash_balance`(≠cash), `asset_quantity`(≠quantity), `broker`(≠broker_type), `active_strategy_id`(≠strategy_id) 사용. `strategy_params` 필드 아예 없음 — trex-basic contract 필드명 불일치 | **BLOCKER** | | AC4 | strategy_params → StrategySet.config 파생 JSON | `AssetItem`에 strategy_params 필드 없음. v1_assets.py에서 StrategySet 조회 로직 없음 | **BLOCKER** | | AC5 | strategy_params {} 폴백 | strategy_params 미구현으로 폴백 불가 | **BLOCKER** | | AC6 | portfolios.market NULL → 응답 null | `AssetItem.market: str | None = None`, `Portfolio.market: str | None = None` — NULL 전파 구조 PASS. NULL 케이스 전용 테스트 없음 | PARTIAL | | AC7 | 미인증 401 | `tests/test_v1.py::test_v1_assets_requires_service_token` — 401 반환 PASS | PASS | **REQ-006 판정: BLOCKED** — AC2(status 필터), AC3(필드명 불일치), AC4/AC5(strategy_params 미구현) --- ### REQ-007: GET /v1/user_info 엔드포인트 | # | Acceptance Criterion | Evidence | Result | |---|---|---|---| | AC1 | 응답에 runtime_id, trading_enabled, brokers[] 포함 | `UserInfoResponse`에 `runtime_id` ✓, `is_active` (trading_enabled 아님) ✓, `brokers[]` 없음. Contract 필드명 `trading_enabled` 사용 안 함 | **BLOCKER** | | AC2 | environment 필드 없음 | `UserInfoResponse`에 environment 필드 없음 | PASS | | AC3 | is_active=true → trading_enabled=true | `trading_enabled` 필드 미구현 | **BLOCKER** | | AC4 | is_active=false → trading_enabled=false, disabled_reason 포함 | `tests/test_v1.py::test_v1_user_info_inactive_user_has_disabled_reason` — `disabled_reason == "account_inactive"` PASS. `trading_enabled` 미구현 | **BLOCKER** | | AC5 | brokers[]에 credential_payload 복호화 JSON | `UserInfoResponse`에 brokers[] 없음. v1_user_info.py에서 broker_credentials 조회 및 복호화 없음 | **BLOCKER** | | AC6 | runtime_id 없는 구형 토큰 → HTTP 404 | `tests/test_v1.py::test_v1_user_info_404_when_no_runtime_id` — 404 + "runtime_id not assigned to this token" PASS | PASS | | AC7 | 미인증 401 | `_require_service_token` 의존성에서 강제. 명시적 테스트 없으나 구조 검토 통과 | PARTIAL | **REQ-007 판정: BLOCKED** — trading_enabled 필드명, brokers[] 배열 전체 미구현 --- ### REQ-008: POST /v1/assets/{asset_id}/ledger/trades | # | Acceptance Criterion | Evidence | Result | |---|---|---|---| | AC1 | 신규 체결 → HTTP 201 + ledger_entry_id | `tests/test_v1.py::test_v1_ledger_trade_created` — 201 + created=True PASS | PASS | | AC2 | 동일 payload 재전송 → HTTP 200 + status="duplicate" | 현재 구현은 중복 시에도 **HTTP 201** 반환, 응답 필드가 `status="duplicate"`가 아닌 `created=false` 사용. `test_v1_ledger_trade_idempotent`는 201을 assert | **BLOCKER** | | AC3 | 동일 키 다른 payload → HTTP 409 | `insert_ledger_trade`에서 UniqueViolation 시 기존 행 반환만 수행, payload 비교 없음. 409 응답 미구현 | **BLOCKER** | | AC4 | 존재하지 않는 asset_id → 404 | `v1_ledger.py`에서 asset_id 존재 여부 확인 로직 없음 | **BLOCKER** | | AC5 | 미인증 401 | `_require_service_token` 의존성에서 강제. 명시적 테스트 없으나 구조 검토 통과 | PARTIAL | **REQ-008 판정: BLOCKED** — AC2(HTTP 상태 코드 및 응답 필드), AC3(409 미구현), AC4(asset_id 존재 확인 없음) --- ### REQ-009: v1 API DB 스키마 migration | # | Acceptance Criterion | Evidence | Result | |---|---|---|---| | AC1 | portfolios.market 컬럼 존재 | `20260502_0014_v1_api_schema.py` — `add_column("portfolios", market TEXT NULLABLE)` IF NOT EXISTS 패턴 구현 — 코드 검토 PASS. 실제 DB 실행 미확인 | PARTIAL (DB 미확인) | | AC2 | auth_tokens.runtime_id 컬럼 존재 | 동일 migration — `add_column("auth_tokens", runtime_id TEXT NULLABLE)` + UNIQUE INDEX — 코드 검토 PASS | PARTIAL (DB 미확인) | | AC3 | broker_credentials 테이블 존재 | 동일 migration — `create_table("broker_credentials", ...)` PK(user_id, broker) — 코드 검토 PASS | PARTIAL (DB 미확인) | | AC4 | ledger_trades 테이블 존재 | 동일 migration — `create_table("ledger_trades", ...)` BIGSERIAL PK — 코드 검토 PASS | PARTIAL (DB 미확인) | | AC5 | (asset_id, order_id, execution_id) UniqueViolation | `uq_ledger_trades_idempotency` UniqueConstraint 정의 확인. DB 실행 미확인 | PARTIAL (DB 미확인) | | AC6 | 재실행 시 에러 없음 | IF NOT EXISTS 패턴 + `_table_exists`/`_column_exists` 검사 구현 — 코드 검토 PASS | PARTIAL (DB 미확인) | | AC7 | 기존 행 market NULL 유지 | `ADD COLUMN ... NULLABLE` 추가만 수행, 기존 행 UPDATE 없음 — 코드 검토 PASS | PARTIAL (DB 미확인) | **REQ-009 판정: PARTIAL** — 코드 검토 전체 PASS. 실제 DB 환경(Docker Compose) 실행 검증 필요. 코드 전용 환경에서는 블로커 아님으로 취급하나, live DB 검증 항목을 Blocking Issues에 포함. --- ### REQ-010: credential_payload Fernet 암호화 및 startup validation | # | Acceptance Criterion | Evidence | Result | |---|---|---|---| | AC1 | KEY 있음 → 정상 기동 | `app/core/encryption.py` 신규 구현, `Settings.credential_encryption_key` 필드 추가. KEY 있을 때 정상 동작 — 코드 검토 PASS | PASS | | AC2 | KEY 없음 → 기동 실패 + 에러 메시지 | 구현에서 lazy validation으로 변경됨: `Settings` 생성 시 에러 없이 통과, `require_credential_encryption_key()` 호출 시에만 RuntimeError 발생. **startup 실패 동작이 AC2와 불일치** | **BLOCKER** | | AC3 | DB에 ciphertext 저장 | `upsert_broker_credential` 메서드 존재. 그러나 API에서 plaintext → encrypt → DB 저장 경로 미구현 (암호화 적용은 호출자 책임으로 처리됨). 전용 테스트 없음 | **BLOCKER** | | AC4 | GET /v1/user_info 복호화 JSON 반환 | `UserInfoResponse`에 brokers[] 없음 (REQ-007 BLOCKED에 포함) — 동일 blocker | **BLOCKER** | | AC5 | 잘못된 키 → InvalidToken 예외 | `encryption.py`에서 `cryptography.fernet.InvalidToken` re-export. 전용 테스트 없음 | PARTIAL | **REQ-010 판정: BLOCKED** — AC2(startup 실패 동작 불일치), AC3(암호화 저장 경로 미연결), AC4(REQ-007 blocker와 연동) --- ## Architecture Impact & Baseline Docs Sync `design.md` Changed Areas에 아래 baseline 문서 갱신이 명시됨: - `docs/project-structure.md` — app/core/encryption.py, app/api/v1_*.py 신규 모듈 반영 - `docs/runtime-flows.md` — v1 API 요청 흐름, runtime_id 발급 흐름 추가 **현재 상태:** `implementation.md` Changed Files에 baseline 문서 갱신 없음. design의 Architecture Impact가 `none`이 아니므로 baseline 문서 동기화 evidence가 필요함. **판정: BLOCKER** — docs/project-structure.md, docs/runtime-flows.md 미갱신 --- ## Evidence ### Automated Tests (pytest) - `tests/test_app.py::test_healthcheck` — PASS (health 필드 검증 포함) - `tests/test_v1.py::test_health_returns_full_response` — PASS (4개 필드 + KST checked_at) - `tests/test_v1.py::test_v1_assets_requires_service_token` — PASS - `tests/test_v1.py::test_v1_assets_rejects_env_token` — PASS - `tests/test_v1.py::test_v1_assets_returns_asset_list` — PASS (asset_id, market 기본 필드) - `tests/test_v1.py::test_v1_user_info_404_when_no_runtime_id` — PASS - `tests/test_v1.py::test_v1_user_info_returns_user` — PASS (runtime_id, disabled_reason=None) - `tests/test_v1.py::test_v1_user_info_inactive_user_has_disabled_reason` — PASS - `tests/test_v1.py::test_v1_ledger_trade_created` — PASS - `tests/test_v1.py::test_v1_ledger_trade_idempotent` — PASS - `tests/test_auth.py` 전체 — PASS (runtime_id 파라미터 추가 후) - `tests/test_cli.py::test_cli_service_token_issue_supports_json` — PASS - 전체 합계: 88 passed (2026-05-02) ### Code Review - `app/core/encryption.py`: Fernet 암복호화 유틸리티 정상 구현 - `app/core/config.py`: `credential_encryption_key` 필드 추가, lazy validation 전환 - `app/core/auth.py`: `AuthContext.runtime_id`, `TokenPrincipal.runtime_id` 추가 - `app/domain/models.py`: `AuthToken.runtime_id`, `Portfolio.market`, `BrokerCredential`, `LedgerTrade` 추가 - `app/services/auth_service.py`: `uuid.uuid4()` runtime_id 생성·전달 - `migrations/versions/20260502_0014_v1_api_schema.py`: IF NOT EXISTS 패턴 정상 ### DB 환경 실행 - 미수행 (Docker Compose 환경 미기동) --- ## Result - **Ready for Release: false** ### Blocking Issues | # | REQ | Issue | 필요 조치 | |---|---|---|---| | B-1 | REQ-005 AC2 | service-token 발급 API 엔드포인트(POST /auth/service-tokens 또는 동등 경로) 미구현. CLI 전용으로 구현됨 | API 엔드포인트 추가 또는 REQ-005 AC2 범위 조정(change-req) | | B-2 | REQ-006 AC2 | ?status=active 쿼리 파라미터 필터 미구현 | v1_assets.py에 status 쿼리 파라미터 추가 | | B-3 | REQ-006 AC3 | 응답 필드명 불일치: cash_balance(≠cash), asset_quantity(≠quantity), broker(≠broker_type), active_strategy_id(≠strategy_id) | 계약 필드명으로 응답 스키마 수정 또는 alias 추가 | | B-4 | REQ-006 AC4/AC5 | strategy_params 필드 미구현. StrategySet.config 조회 및 {} 폴백 로직 없음 | v1_assets.py에 strategy_set 조회 및 strategy_params 파생 로직 추가 | | B-5 | REQ-007 AC1/AC3/AC4 | trading_enabled 필드 미구현(is_active로 대체됨). 계약 필드명 불일치 | UserInfoResponse에 trading_enabled 필드 추가 | | B-6 | REQ-007 AC5 | brokers[] 배열 전체 미구현. broker_credentials 조회 + Fernet 복호화 경로 없음 | v1_user_info.py에 broker_credentials 조회 + 복호화 + 응답 포함 구현 | | B-7 | REQ-008 AC2 | 중복 수신 시 HTTP 201 반환(AC는 HTTP 200 요구). 응답 필드 created(bool) vs status="duplicate" 불일치 | 중복 시 HTTP 200 반환, 응답 스키마에 status="duplicate" 필드 추가 | | B-8 | REQ-008 AC3 | 동일 키 다른 payload → HTTP 409 미구현 | insert_ledger_trade에 payload 비교 로직 및 409 응답 추가 | | B-9 | REQ-008 AC4 | 존재하지 않는 asset_id → HTTP 404 미구현 | v1_ledger.py에 asset_id(portfolio_id) 존재 여부 확인 후 404 반환 추가 | | B-10 | REQ-010 AC2 | startup validation 동작 불일치: Settings 생성 시 에러 없음(lazy), AC는 기동 실패 요구 | lifespan 또는 애플리케이션 시작 시 require_credential_encryption_key() 호출 추가 | | B-11 | REQ-010 AC3 | broker_credentials 저장 시 암호화 미적용. API 레이어에서 encrypt_payload 호출 없음 | upsert_broker_credential 호출부 또는 API 레이어에서 암호화 적용 | | B-12 | Baseline | docs/project-structure.md, docs/runtime-flows.md 미갱신. design.md Architecture Impact에 갱신 필요로 명시됨 | 두 baseline 문서에 신규 모듈 및 v1 API 흐름 반영 | ### Non-Blocking Observations | # | REQ | Observation | |---|---|---| | N-1 | REQ-009 | migration 파일 코드 검토 PASS. DB 환경(Docker Compose) 실행 미검증 — 배포 전 반드시 확인 필요 | | N-2 | REQ-004 AC2 | degraded/failed 상태 경로 미구현 — REQ Notes에서 이번 반복 OOS로 명시되어 릴리스 블로커 아님 | | N-3 | REQ-005 AC4 | 두 번 발급 시 다른 runtime_id — 코드 검토 통과. 전용 테스트 없음 | | N-4 | REQ-010 AC5 | InvalidToken 경로 전용 테스트 없음 |