from __future__ import annotations import json import uuid from datetime import datetime, timezone import app.cli.commands.health as health_commands from typer.testing import CliRunner from app.cli.main import create_app from app.core.config import Settings from app.core.security import hash_token, token_prefix from app.domain.models import AuthToken, BrokerCredential, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User class FakeRepo: def __init__(self, portfolio: Portfolio | None = None) -> None: self._portfolio = portfolio self.entries: list[LedgerEntry] = [] self.strategy_set = StrategySet(id="set-1", name="target_value", config="{}", nick="target_value:99914b932bd3") self._strategy_sets: dict[str, StrategySet] = {self.strategy_set.id: self.strategy_set} self._users: dict[str, User] = {} self._user_seq = 1 self._comments: dict[str, PortfolioComment] = {} self._tags: dict[str, PortfolioTag] = {} self._auth_tokens: dict[str, AuthToken] = {} self._token_seq = 1 self._broker_credentials: dict[tuple[str, str], BrokerCredential] = {} self.closed = False def close(self) -> None: self.closed = True def ensure_schema(self) -> None: return None def ping(self) -> None: return None def get_user(self, user_id: str) -> User: user = self._users.get(user_id) if user is None: raise ValueError("사용자를 찾을 수 없습니다") return user def list_users(self) -> list[User]: return sorted(self._users.values(), key=lambda user: user.username) def create_user( self, username: str, *, email: str | None = None, password_hash: str | None = None, permission: str = "read", is_active: bool = True, ) -> User: if any(user.username == username for user in self._users.values()): raise ValueError(f"이미 존재하는 username입니다: {username}") if email is not None and any(user.email == email for user in self._users.values()): raise ValueError(f"이미 존재하는 email입니다: {email}") now = datetime.now(timezone.utc) user = User( id=f"user-{self._user_seq}", username=username, email=email, permission=permission, is_active=is_active, password_hash=password_hash, created_at=now, updated_at=now, ) self._user_seq += 1 self._users[user.id] = user return user def update_user( self, user_id: str, *, username: str | None = None, email: str | None = None, password_hash: str | None = None, permission: str | None = None, is_active: bool | None = None, ) -> User: user = self.get_user(user_id) if username is not None and any(item.id != user_id and item.username == username for item in self._users.values()): raise ValueError(f"이미 존재하는 username입니다: {username}") if email is not None and any(item.id != user_id and item.email == email for item in self._users.values()): raise ValueError(f"이미 존재하는 email입니다: {email}") if username is not None: user.username = username if email is not None: user.email = email if password_hash is not None: user.password_hash = password_hash if permission is not None: user.permission = permission if is_active is not None: user.is_active = is_active user.updated_at = datetime.now(timezone.utc) return user def delete_user(self, user_id: str) -> None: if user_id not in self._users: raise ValueError("사용자를 찾을 수 없습니다") del self._users[user_id] def get_user_by_username(self, username: str): return next((u for u in self._users.values() if u.username == username), None) def get_service_token_by_name(self, name: str) -> AuthToken | None: return next((t for t in self._auth_tokens.values() if t.name == name and t.token_type == "service"), None) def create_auth_token( self, user_id: str, *, name: str, token_hash: str, token_prefix: str, permission: str, token_type: str, expires_at: datetime | None = None, runtime_id: str | None = None, ) -> AuthToken: token = AuthToken( id=f"token-{self._token_seq}", user_id=user_id, name=name, token_prefix=token_prefix, permission=permission, token_type=token_type, expires_at=expires_at, created_at=datetime.now(timezone.utc), runtime_id=runtime_id, ) self._token_seq += 1 self._auth_tokens[token.id] = token return token def revoke_auth_token(self, user_id: str, token_id: str) -> None: token = self._auth_tokens.get(token_id) if token is None or token.user_id != user_id: raise ValueError("토큰을 찾을 수 없습니다") token.revoked_at = datetime.now(timezone.utc) def rotate_service_token( self, token_id: str, token_hash: str, token_prefix: str, runtime_id: str, ) -> AuthToken: token = self._auth_tokens.get(token_id) if token is None or token.revoked_at is not None: raise ValueError("토큰을 찾을 수 없습니다") token.token_prefix = token_prefix token.runtime_id = runtime_id return token def list_service_tokens( self, user_id: str | None = None, *, include_revoked: bool = False, ) -> list[AuthToken]: results = [ t for t in self._auth_tokens.values() if t.token_type == "service" and (user_id is None or t.user_id == user_id) and (include_revoked or t.revoked_at is None) ] return sorted(results, key=lambda t: t.created_at or datetime.min, reverse=True) def get(self, portfolio_id: str) -> Portfolio: if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id: raise ValueError("포트폴리오를 찾을 수 없습니다") return self._portfolio def get_or_create( self, portfolio_id: str, symbol: str, *, name: str | None = None, broker: str = "kis", status: str = "enabled", active_strategy_id: str | None = None, avabile_strategies: list[str] | None = None, owner_user_id: str | None = None, initial_cash: float = 0.0, ) -> Portfolio: if self._portfolio is None: self._portfolio = Portfolio( portfolio_id=portfolio_id, broker=broker, status=status, name=name or symbol, symbol=symbol, owner_user_id=owner_user_id, cash_balance=initial_cash, active_strategy_id=active_strategy_id, avabile_strategies=avabile_strategies, ) return self._portfolio def create_portfolio( self, portfolio_id: str, *, owner_user_id: str, broker: str, status: str, name: str, symbol: str, cash_balance: float, asset_quantity: float, asset_price: float, profit_buffer: float, active_strategy_id: str | None, avabile_strategies: list[str] | None, ) -> Portfolio: self._portfolio = Portfolio( portfolio_id=portfolio_id, broker=broker, status=status, name=name, symbol=symbol, owner_user_id=owner_user_id, cash_balance=cash_balance, asset_quantity=asset_quantity, price=asset_price, profit_buffer=profit_buffer, active_strategy_id=active_strategy_id, avabile_strategies=avabile_strategies, ) return self._portfolio def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: portfolio = self.get(portfolio_id) for key, value in fields.items(): if value is not None: setattr(portfolio, key, value) return portfolio def list_portfolios( self, *, broker: str | None = None, status: str | None = None, owner_user_id: str | None = None, include_tags: list[str] | None = None, exclude_tags: list[str] | None = None, match_mode: str | None = None, ) -> list[Portfolio]: if self._portfolio is None: return [] if broker is not None and self._portfolio.broker != broker: return [] if status is not None and self._portfolio.status != status: return [] if owner_user_id is not None and self._portfolio.owner_user_id != owner_user_id: return [] tags = set(self._portfolio.tags or []) if include_tags: required = set(include_tags) mode = (match_mode or "OR").upper() if mode == "AND" and not required.issubset(tags): return [] if mode != "AND" and tags.isdisjoint(required): return [] if exclude_tags and tags.intersection(exclude_tags): return [] return [self._portfolio] def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag: portfolio = self.get(portfolio_id) normalized = tag.strip() key = f"{portfolio_id}:{normalized}" if key in self._tags: raise ValueError(f"이미 연결된 태그입니다: {normalized}") created = datetime.now(timezone.utc) item = PortfolioTag(portfolio_id=portfolio_id, tag=normalized, created_at=created) self._tags[key] = item portfolio.tags = sorted([*list(portfolio.tags or []), normalized]) return item def list_tags(self, portfolio_id: str) -> list[PortfolioTag]: self.get(portfolio_id) return sorted( [tag for tag in self._tags.values() if tag.portfolio_id == portfolio_id], key=lambda item: item.tag, ) def remove_tag(self, portfolio_id: str, *, tag: str) -> None: portfolio = self.get(portfolio_id) normalized = tag.strip() key = f"{portfolio_id}:{normalized}" if key not in self._tags: raise ValueError("태그를 찾을 수 없습니다") del self._tags[key] portfolio.tags = [item for item in (portfolio.tags or []) if item != normalized] def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: self.strategy_set = StrategySet(id="set-1", name=name, config=config, nick=f"{name}:99914b932bd3") self._strategy_sets[self.strategy_set.id] = self.strategy_set return self.strategy_set def get_strategy_set(self, id: str) -> StrategySet | None: return self.strategy_set if self.strategy_set.id == id else None def list_strategy_sets(self) -> list[StrategySet]: from datetime import datetime, timezone return sorted(self._strategy_sets.values(), key=lambda s: s.created_at or datetime.now(timezone.utc), reverse=True) def delete_strategy_set(self, id: str) -> None: if id in self._strategy_sets: del self._strategy_sets[id] def save( self, portfolio: Portfolio, *, current_price: float, action: str, trade_price: float, quantity: float, fee_amount: float, tax_amount: float, total_value_after: float, strategy_set_id: str = "manual", note: str = "", ) -> None: self._portfolio = portfolio self.entries.insert( 0, LedgerEntry( portfolio_id=portfolio.portfolio_id, recorded_at=datetime.now(timezone.utc), action=action, price=trade_price, quantity=quantity, fee_amount=fee_amount, tax_amount=tax_amount, cash_balance_after=portfolio.cash_balance, asset_quantity_after=portfolio.asset_quantity, total_value_after=total_value_after, profit_buffer_after=portfolio.profit_buffer, strategy_set_id=strategy_set_id, note=note, ), ) def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]: return self.entries[:limit] def reset(self, portfolio_id: str) -> Portfolio: portfolio = self.get(portfolio_id) portfolio.cash_balance = 0.0 portfolio.asset_quantity = 0.0 portfolio.price = 0.0 portfolio.profit_buffer = 0.0 portfolio.active_strategy_id = None self.entries = [] return portfolio def adjust_buffer(self, portfolio_id: str, amount: float, action: str, note: str = "") -> Portfolio: portfolio = self.get(portfolio_id) if action == "INVEST": portfolio.profit_buffer += amount else: portfolio.profit_buffer -= amount return portfolio def transfer_buffer(self, portfolio_id: str, amount: float, direction: str, note: str = "") -> Portfolio: portfolio = self.get(portfolio_id) if direction == "to-buffer": portfolio.cash_balance -= amount portfolio.profit_buffer += amount else: portfolio.cash_balance += amount portfolio.profit_buffer -= amount return portfolio def delete(self, portfolio_id: str, *, clear_ledger: bool = True) -> None: self._portfolio = None if clear_ledger: self.entries = [] def create_comment(self, portfolio_id: str, *, content: str) -> PortfolioComment: if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id: raise ValueError("포트폴리오를 찾을 수 없습니다") now = datetime.now(timezone.utc) comment = PortfolioComment( id=str(uuid.uuid4()), portfolio_id=portfolio_id, content=content, created_at=now, updated_at=now, ) self._comments[comment.id] = comment return comment def list_comments(self, portfolio_id: str) -> list[PortfolioComment]: if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id: raise ValueError("포트폴리오를 찾을 수 없습니다") return sorted( [c for c in self._comments.values() if c.portfolio_id == portfolio_id], key=lambda c: c.created_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True, ) def update_comment(self, portfolio_id: str, comment_id: str, *, content: str) -> PortfolioComment: if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id: raise ValueError("포트폴리오를 찾을 수 없습니다") comment = self._comments.get(comment_id) if comment is None or comment.portfolio_id != portfolio_id: raise ValueError("댓글을 찾을 수 없습니다") comment.content = content comment.updated_at = datetime.now(timezone.utc) return comment def delete_comment(self, portfolio_id: str, comment_id: str) -> None: if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id: raise ValueError("포트폴리오를 찾을 수 없습니다") comment = self._comments.get(comment_id) if comment is None or comment.portfolio_id != portfolio_id: raise ValueError("댓글을 찾을 수 없습니다") del self._comments[comment_id] # --- broker credentials --- def upsert_broker_credential( self, user_id: str, broker: str, *, account_ref: str, encrypted_payload: str, ) -> BrokerCredential: key = (user_id, broker) existing = self._broker_credentials.get(key) version = (existing.credential_version + 1) if existing else 1 credential = BrokerCredential( user_id=user_id, broker=broker, account_ref=account_ref, encrypted_payload=encrypted_payload, credential_version=version, last_rotated_at=datetime.now(timezone.utc), ) self._broker_credentials[key] = credential return credential def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]: return [c for (uid, _), c in self._broker_credentials.items() if uid == user_id] def get_broker_credential(self, user_id: str, broker: str) -> BrokerCredential | None: return self._broker_credentials.get((user_id, broker)) def delete_broker_credential(self, user_id: str, broker: str) -> bool: key = (user_id, broker) if key in self._broker_credentials: del self._broker_credentials[key] return True return False def _settings() -> Settings: return Settings(db_enabled=True, allowed_origins_raw="http://localhost:3000") def build_runner(repo: FakeRepo) -> CliRunner: app = create_app(settings_factory=_settings, repository_factory=lambda settings: repo) runner = CliRunner() runner.app = app return runner def _portfolio() -> Portfolio: return Portfolio( portfolio_id="005930:tv", broker="kis", status="enabled", name="Samsung Electronics", symbol="005930", cash_balance=1_000_000.0, asset_quantity=1.0, price=70_000.0, profit_buffer=50_000.0, active_strategy_id="set-1", avabile_strategies=["set-1"], ) def test_cli_list_renders_table() -> None: repo = FakeRepo() user = repo.create_user("alice", permission="read") p = _portfolio() p.owner_user_id = user.id repo._portfolio = p runner = build_runner(repo) result = runner.invoke(runner.app, ["portfolio", "list", "--user", "alice"]) assert result.exit_code == 0 assert "005930:tv" in result.stdout assert "id: 005930:tv" in result.stdout def test_cli_status_supports_json_output() -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) result = runner.invoke(runner.app, ["--format", "json", "portfolio", "status", "005930:tv"]) assert result.exit_code == 0 payload = json.loads(result.stdout) assert payload["portfolio"]["portfolio_id"] == "005930:tv" def test_cli_create_buy_and_delete_flow() -> None: repo = FakeRepo() user = repo.create_user("alice", permission="read") runner = build_runner(repo) create_result = runner.invoke( runner.app, [ "portfolio", "create", "005930:tv", "--symbol", "005930", "--user", "alice", "--cash-balance", "1000000", "--strategy-params", '{"target_asset_ratio": 0.5}', "--force", ], ) buy_result = runner.invoke( runner.app, [ "portfolio", "buy", "005930:tv", "--quantity", "2", "--price", "50000", "--fee", "100", "--force", ], ) assert repo.entries[0].action == "BUY" assert repo._portfolio.owner_user_id == user.id delete_result = runner.invoke(runner.app, ["portfolio", "delete", "005930:tv", "--keep-ledger", "--force"]) assert create_result.exit_code == 0 assert buy_result.exit_code == 0 assert delete_result.exit_code == 0 assert "Deleted portfolio: 005930:tv" in delete_result.stdout def test_cli_write_command_prompts_before_create() -> None: repo = FakeRepo() repo.create_user("alice", permission="read") runner = build_runner(repo) result = runner.invoke( runner.app, [ "portfolio", "create", "005930:tv", "--symbol", "005930", "--user", "alice", "--cash-balance", "1000000", ], input="y\n", ) assert result.exit_code == 0 assert "Create portfolio 005930:tv for symbol 005930?" in result.stdout def test_cli_write_command_can_abort() -> None: repo = FakeRepo() repo.create_user("alice", permission="read") runner = build_runner(repo) result = runner.invoke( runner.app, [ "portfolio", "create", "005930:tv", "--symbol", "005930", "--user", "alice", "--cash-balance", "1000000", ], input="n\n", ) assert result.exit_code == 0 assert repo._portfolio is None def test_cli_portfolio_create_without_user_exits_error() -> None: repo = FakeRepo() runner = build_runner(repo) result = runner.invoke( runner.app, ["portfolio", "create", "005930:tv", "--symbol", "005930", "--force"], ) assert result.exit_code == 1 assert "--user" in result.stdout def test_cli_portfolio_create_with_valid_user_sets_owner() -> None: repo = FakeRepo() user = repo.create_user("alice", permission="read") runner = build_runner(repo) result = runner.invoke( runner.app, ["portfolio", "create", "005930:tv", "--symbol", "005930", "--user", "alice", "--force"], ) assert result.exit_code == 0 assert repo._portfolio is not None assert repo._portfolio.owner_user_id == user.id def test_cli_portfolio_create_with_unknown_user_exits_error() -> None: repo = FakeRepo() runner = build_runner(repo) result = runner.invoke( runner.app, ["portfolio", "create", "005930:tv", "--symbol", "005930", "--user", "nobody", "--force"], ) assert result.exit_code == 1 assert "nobody" in result.stdout def test_cli_health_check_reports_repository() -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) result = runner.invoke(runner.app, ["health-check"]) assert result.exit_code == 0 assert "repository: FakeRepo" in result.stdout def test_cli_init_db_reports_created(monkeypatch) -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) def fake_initialize_database(*, settings, maintenance_db: str = "postgres") -> dict[str, object]: return { "database": settings.active_db_name, "host": settings.active_db_host, "port": settings.active_db_port, "created": True, "schema_initialized": True, "migration_applied": "head", } monkeypatch.setattr(health_commands, "initialize_database", fake_initialize_database) result = runner.invoke(runner.app, ["init-db"]) assert result.exit_code == 0 assert "created: True" in result.stdout assert "schema_initialized: True" in result.stdout assert "migration_applied: head" in result.stdout def test_cli_init_db_supports_json(monkeypatch) -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) def fake_initialize_database(*, settings, maintenance_db: str = "postgres") -> dict[str, object]: return { "database": "portfoliohub", "host": "localhost", "port": 5432, "created": False, "schema_initialized": True, "migration_applied": "head", } monkeypatch.setattr(health_commands, "initialize_database", fake_initialize_database) result = runner.invoke(runner.app, ["--format", "json", "init-db"]) assert result.exit_code == 0 payload = json.loads(result.stdout) assert payload["database"] == "portfoliohub" assert payload["schema_initialized"] is True def test_cli_migrate_db_reports_head(monkeypatch) -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) def fake_bootstrap_or_upgrade_to_head(settings) -> None: return None monkeypatch.setattr(health_commands, "bootstrap_or_upgrade_to_head", fake_bootstrap_or_upgrade_to_head) result = runner.invoke(runner.app, ["migrate-db"]) assert result.exit_code == 0 assert "migration_applied: head" in result.stdout def test_cli_user_list_renders_table() -> None: repo = FakeRepo() repo.create_user("alice", email="alice@example.com") runner = build_runner(repo) result = runner.invoke(runner.app, ["user", "list"]) assert result.exit_code == 0 assert "Users" in result.stdout assert "alice" in result.stdout def test_cli_user_create_supports_json() -> None: repo = FakeRepo() runner = build_runner(repo) result = runner.invoke( runner.app, [ "--format", "json", "user", "create", "--username", "alice", "--password", "password123", "--email", "alice@example.com", "--force", ], ) assert result.exit_code == 0 payload = json.loads(result.stdout) assert payload["username"] == "alice" assert payload["email"] == "alice@example.com" def test_cli_user_update_and_delete_flow() -> None: repo = FakeRepo() user = repo.create_user("alice", email="alice@example.com") runner = build_runner(repo) update_result = runner.invoke( runner.app, ["user", "update", user.id, "--username", "alice-updated", "--force"], ) delete_result = runner.invoke(runner.app, ["user", "delete", user.id, "--force"]) assert update_result.exit_code == 0 assert "alice-updated" in update_result.stdout assert delete_result.exit_code == 0 assert f"Deleted user: {user.id}" in delete_result.stdout def test_cli_comment_add_and_list() -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) add_result = runner.invoke( runner.app, ["portfolio", "comment", "add", "005930:tv", "--content", "First comment"], ) list_result = runner.invoke(runner.app, ["portfolio", "comment", "list", "005930:tv"]) assert add_result.exit_code == 0 assert "First comment" in add_result.stdout assert list_result.exit_code == 0 assert "First comment" in list_result.stdout def test_cli_comment_add_supports_json_output() -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) result = runner.invoke( runner.app, ["--format", "json", "portfolio", "comment", "add", "005930:tv", "--content", "JSON comment"], ) assert result.exit_code == 0 payload = json.loads(result.stdout) assert payload["content"] == "JSON comment" assert payload["portfolio_id"] == "005930:tv" assert "id" in payload def test_cli_comment_update_flow() -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) add_result = runner.invoke( runner.app, ["--format", "json", "portfolio", "comment", "add", "005930:tv", "--content", "Original"], ) assert add_result.exit_code == 0 comment_id = json.loads(add_result.stdout)["id"] update_result = runner.invoke( runner.app, [ "--format", "json", "portfolio", "comment", "update", "005930:tv", comment_id, "--content", "Updated", "--force", ], ) assert update_result.exit_code == 0 updated = json.loads(update_result.stdout) assert updated["content"] == "Updated" assert updated["id"] == comment_id def test_cli_comment_delete_flow() -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) add_result = runner.invoke( runner.app, ["--format", "json", "portfolio", "comment", "add", "005930:tv", "--content", "To delete"], ) assert add_result.exit_code == 0 comment_id = json.loads(add_result.stdout)["id"] delete_result = runner.invoke( runner.app, ["portfolio", "comment", "delete", "005930:tv", comment_id, "--force"], ) list_result = runner.invoke(runner.app, ["portfolio", "comment", "list", "005930:tv"]) assert delete_result.exit_code == 0 assert comment_id in delete_result.stdout assert "To delete" not in list_result.stdout def test_cli_comment_multiline_content() -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) multiline = "Line 1\nLine 2\nLine 3" result = runner.invoke( runner.app, ["--format", "json", "portfolio", "comment", "add", "005930:tv", "--content", multiline], ) assert result.exit_code == 0 payload = json.loads(result.stdout) assert payload["content"] == multiline def test_cli_comment_list_nonexistent_portfolio() -> None: repo = FakeRepo() runner = build_runner(repo) result = runner.invoke(runner.app, ["portfolio", "comment", "list", "nonexistent:id"]) assert result.exit_code == 1 def test_cli_tag_add_and_list() -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) add_result = runner.invoke(runner.app, ["portfolio", "tag", "add", "005930:tv", "--tag", " grade:A "]) list_result = runner.invoke(runner.app, ["--format", "json", "portfolio", "tag", "list", "005930:tv"]) assert add_result.exit_code == 0 assert "grade:A" in add_result.stdout assert list_result.exit_code == 0 payload = json.loads(list_result.stdout) assert payload[0]["tag"] == "grade:A" def test_cli_list_supports_tag_filters() -> None: repo = FakeRepo() user = repo.create_user("alice", permission="read") p = _portfolio() p.owner_user_id = user.id repo._portfolio = p repo.add_tag("005930:tv", tag="grade:A") runner = build_runner(repo) result = runner.invoke(runner.app, ["portfolio", "list", "--include-tag", "grade:A", "--match-mode", "AND", "--user", "alice"]) assert result.exit_code == 0 assert "005930:tv" in result.stdout def test_cli_tag_remove_flow() -> None: repo = FakeRepo(_portfolio()) repo.add_tag("005930:tv", tag="grade:A") runner = build_runner(repo) result = runner.invoke(runner.app, ["portfolio", "tag", "remove", "005930:tv", "--tag", "grade:A", "--force"]) assert result.exit_code == 0 assert "Removed tag: grade:A" in result.stdout def test_cli_help_exposes_bot_token_commands() -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) result = runner.invoke(runner.app, ["service-token", "--help"]) assert result.exit_code == 0 assert "issue" in result.stdout assert "revoke" in result.stdout def test_cli_service_token_issue_supports_json() -> None: repo = FakeRepo(_portfolio()) user = repo.create_user("alice", permission="read") runner = build_runner(repo) result = runner.invoke( runner.app, ["--format", "json", "service-token", "issue", "nightly", "--user", "alice"], ) assert result.exit_code == 0 payload = json.loads(result.stdout) assert payload["name"] == "nightly" assert payload["revoked"] is False assert payload["expires_at"] is not None def test_cli_service_token_issue_rejects_conflicting_expiry_options() -> None: repo = FakeRepo(_portfolio()) repo.create_user("alice", permission="read") runner = build_runner(repo) result = runner.invoke( runner.app, [ "service-token", "issue", "nightly", "--user", "alice", "--ttl-days", "7", "--no-expiry", ], ) assert result.exit_code == 1 assert "동시에 사용할 수 없습니다" in result.stdout def test_cli_service_token_revoke_can_abort() -> None: repo = FakeRepo(_portfolio()) user = repo.create_user("alice", permission="read") token = repo.create_auth_token( user.id, name="to-revoke", token_hash=hash_token("token-value"), token_prefix=token_prefix("token-value"), permission="read", token_type="service", ) runner = build_runner(repo) result = runner.invoke( runner.app, ["service-token", "revoke", "to-revoke"], input="n\n", ) assert result.exit_code == 0 assert repo._auth_tokens[token.id].revoked_at is None def test_cli_service_token_revoke_force_succeeds() -> None: repo = FakeRepo(_portfolio()) user = repo.create_user("alice", permission="read") token = repo.create_auth_token( user.id, name="to-revoke", token_hash=hash_token("token-value"), token_prefix=token_prefix("token-value"), permission="read", token_type="service", ) runner = build_runner(repo) result = runner.invoke( runner.app, ["--format", "json", "service-token", "revoke", "to-revoke", "--force"], ) assert result.exit_code == 0 payload = json.loads(result.stdout) assert payload["name"] == "to-revoke" assert payload["revoked"] is True def test_cli_service_token_list_returns_active_tokens() -> None: repo = FakeRepo(_portfolio()) user = repo.create_user("alice", permission="read") repo.create_auth_token( user.id, name="active-token", token_hash=hash_token("tok-1"), token_prefix=token_prefix("tok-1"), permission="read", token_type="service", ) runner = build_runner(repo) result = runner.invoke(runner.app, ["--format", "json", "service-token", "list"]) assert result.exit_code == 0 payload = json.loads(result.stdout) assert isinstance(payload, list) assert len(payload) == 1 assert payload[0]["name"] == "active-token" def test_cli_service_token_list_excludes_revoked_by_default() -> None: repo = FakeRepo(_portfolio()) user = repo.create_user("alice", permission="read") token = repo.create_auth_token( user.id, name="old-token", token_hash=hash_token("tok-2"), token_prefix=token_prefix("tok-2"), permission="read", token_type="service", ) repo.revoke_auth_token(user.id, token.id) runner = build_runner(repo) result = runner.invoke(runner.app, ["--format", "json", "service-token", "list"]) assert result.exit_code == 0 payload = json.loads(result.stdout) assert payload == [] def test_cli_service_token_list_show_revoked_includes_revoked() -> None: repo = FakeRepo(_portfolio()) user = repo.create_user("alice", permission="read") token = repo.create_auth_token( user.id, name="old-token", token_hash=hash_token("tok-3"), token_prefix=token_prefix("tok-3"), permission="read", token_type="service", ) repo.revoke_auth_token(user.id, token.id) runner = build_runner(repo) result = runner.invoke( runner.app, ["--format", "json", "service-token", "list", "--show-revoked"] ) assert result.exit_code == 0 payload = json.loads(result.stdout) assert len(payload) == 1 assert payload[0]["name"] == "old-token" assert payload[0]["revoked_at"] is not None def test_cli_service_token_list_filter_by_user() -> None: repo = FakeRepo(_portfolio()) alice = repo.create_user("alice", permission="read") bob = repo.create_user("bob", permission="read") repo.create_auth_token( alice.id, name="alice-token", token_hash=hash_token("tok-a"), token_prefix=token_prefix("tok-a"), permission="read", token_type="service", ) repo.create_auth_token( bob.id, name="bob-token", token_hash=hash_token("tok-b"), token_prefix=token_prefix("tok-b"), permission="read", token_type="service", ) runner = build_runner(repo) result = runner.invoke( runner.app, ["--format", "json", "service-token", "list", "--user", "alice"] ) assert result.exit_code == 0 payload = json.loads(result.stdout) assert len(payload) == 1 assert payload[0]["name"] == "alice-token" def test_cli_service_token_list_unknown_user_exits_error() -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) result = runner.invoke( runner.app, ["service-token", "list", "--user", "nobody"] ) assert result.exit_code == 1 assert "nobody" in result.stdout def test_cli_service_token_list_empty_message() -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) result = runner.invoke(runner.app, ["service-token", "list"]) assert result.exit_code == 0 assert "No service tokens found." in result.stdout # --------------------------------------------------------------------------- # service-token rotate # --------------------------------------------------------------------------- def test_cli_service_token_rotate_no_expiry_succeeds() -> None: repo = FakeRepo(_portfolio()) user = repo.create_user("alice", permission="read") # no-expiry 토큰 발급 (expires_at=None) repo.create_auth_token( user.id, name="permanent", token_hash="oldhash", token_prefix="oldpref", permission="read", token_type="service", expires_at=None, runtime_id="old-runtime-id", ) runner = build_runner(repo) result = runner.invoke(runner.app, ["service-token", "rotate", "permanent", "--force"]) assert result.exit_code == 0 assert "token:" in result.stdout # token_prefix가 새 값으로 교체되었는지 확인 rotated = repo.get_service_token_by_name("permanent") assert rotated is not None assert rotated.token_prefix != "oldpref" assert rotated.runtime_id != "old-runtime-id" def test_cli_service_token_rotate_unknown_name_exits_error() -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) result = runner.invoke(runner.app, ["service-token", "rotate", "nonexistent", "--force"]) assert result.exit_code == 1 assert "nonexistent" in result.stdout def test_cli_service_token_rotate_revoked_token_exits_error() -> None: repo = FakeRepo(_portfolio()) user = repo.create_user("alice", permission="read") token = repo.create_auth_token( user.id, name="old-token", token_hash="hash", token_prefix="pref", permission="read", token_type="service", expires_at=None, runtime_id="rid", ) repo.revoke_auth_token(user.id, token.id) runner = build_runner(repo) result = runner.invoke(runner.app, ["service-token", "rotate", "old-token", "--force"]) assert result.exit_code == 1 assert "old-token" in result.stdout def test_cli_service_token_rotate_with_expiry_exits_error() -> None: repo = FakeRepo(_portfolio()) user = repo.create_user("alice", permission="read") repo.create_auth_token( user.id, name="expiring", token_hash="hash", token_prefix="pref", permission="read", token_type="service", expires_at=datetime(2027, 1, 1, tzinfo=timezone.utc), runtime_id="rid", ) runner = build_runner(repo) result = runner.invoke(runner.app, ["service-token", "rotate", "expiring", "--force"]) assert result.exit_code == 1 assert "expiring" in result.stdout def test_cli_service_token_rotate_can_abort() -> None: repo = FakeRepo(_portfolio()) user = repo.create_user("alice", permission="read") repo.create_auth_token( user.id, name="permanent", token_hash="hash", token_prefix="pref", permission="read", token_type="service", expires_at=None, runtime_id="rid", ) runner = build_runner(repo) result = runner.invoke(runner.app, ["service-token", "rotate", "permanent"], input="n\n") assert result.exit_code == 0 # token_prefix 변경 없음 token = repo.get_service_token_by_name("permanent") assert token is not None assert token.token_prefix == "pref" # --------------------------------------------------------------------------- # portfolio list --user (REQ-013) # --------------------------------------------------------------------------- def test_cli_portfolio_list_without_user_exits_error() -> None: repo = FakeRepo() runner = build_runner(repo) result = runner.invoke(runner.app, ["portfolio", "list"]) assert result.exit_code == 1 assert "--user" in result.stdout def test_cli_portfolio_list_with_valid_user_filters_by_owner() -> None: repo = FakeRepo() alice = repo.create_user("alice", permission="read") bob = repo.create_user("bob", permission="read") alice_portfolio = _portfolio() alice_portfolio.owner_user_id = alice.id repo._portfolio = alice_portfolio runner = build_runner(repo) result_alice = runner.invoke(runner.app, ["portfolio", "list", "--user", "alice"]) result_bob = runner.invoke(runner.app, ["portfolio", "list", "--user", "bob"]) assert result_alice.exit_code == 0 assert "005930:tv" in result_alice.stdout assert result_bob.exit_code == 0 assert "005930:tv" not in result_bob.stdout def test_cli_portfolio_list_with_unknown_user_exits_error() -> None: repo = FakeRepo() runner = build_runner(repo) result = runner.invoke(runner.app, ["portfolio", "list", "--user", "nobody"]) assert result.exit_code == 1 assert "nobody" in result.stdout def test_cli_portfolio_list_in_memory_mode_filters_owner() -> None: repo = FakeRepo() user = repo.create_user("alice", permission="read") p = _portfolio() p.owner_user_id = user.id repo._portfolio = p runner = build_runner(repo) result = runner.invoke(runner.app, ["portfolio", "list", "--user", "alice"]) assert result.exit_code == 0 assert "005930:tv" in result.stdout # --------------------------------------------------------------------------- # credential command tests # --------------------------------------------------------------------------- _TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" def _settings_with_enc_key() -> Settings: return Settings( db_enabled=True, allowed_origins_raw="http://localhost:3000", credential_encryption_key=_TEST_ENC_KEY, ) def build_runner_with_enc_key(repo: FakeRepo) -> CliRunner: app = create_app(settings_factory=_settings_with_enc_key, repository_factory=lambda settings: repo) runner = CliRunner() runner.app = app return runner def test_cli_credential_set_creates_record() -> None: repo = FakeRepo() repo.create_user("alice", permission="write") runner = build_runner_with_enc_key(repo) result = runner.invoke( runner.app, [ "credential", "set", "--user", "alice", "--broker", "kis", "--account-ref", "12345678", "--app-key", "PS_APP_KEY", "--app-secret", "PS_APP_SECRET", "--force", ], ) assert result.exit_code == 0 assert "kis" in result.stdout assert "12345678" in result.stdout assert "PS_APP_KEY" not in result.stdout # encrypted_payload 미출력 확인 def test_cli_credential_list_hides_payload() -> None: repo = FakeRepo() repo.create_user("alice", permission="write") runner = build_runner_with_enc_key(repo) runner.invoke( runner.app, [ "credential", "set", "--user", "alice", "--broker", "kis", "--account-ref", "12345678", "--app-key", "MYKEY", "--app-secret", "MYSECRET", "--force", ], ) result = runner.invoke(runner.app, ["credential", "list", "--user", "alice"]) assert result.exit_code == 0 assert "kis" in result.stdout assert "12345678" in result.stdout assert "MYKEY" not in result.stdout assert "MYSECRET" not in result.stdout def test_cli_credential_set_with_unknown_user_exits_error() -> None: repo = FakeRepo() runner = build_runner_with_enc_key(repo) result = runner.invoke( runner.app, [ "credential", "set", "--user", "ghost", "--broker", "kis", "--account-ref", "REF", "--app-key", "KEY", "--app-secret", "SECRET", "--force", ], ) assert result.exit_code == 1 assert "ghost" in result.stdout def test_cli_credential_remove_deletes_record() -> None: repo = FakeRepo() repo.create_user("alice", permission="write") runner = build_runner_with_enc_key(repo) runner.invoke( runner.app, [ "credential", "set", "--user", "alice", "--broker", "kis", "--account-ref", "REF", "--app-key", "KEY", "--app-secret", "SECRET", "--force", ], ) remove_result = runner.invoke( runner.app, ["credential", "remove", "--user", "alice", "--broker", "kis", "--force"], ) list_result = runner.invoke(runner.app, ["credential", "list", "--user", "alice"]) assert remove_result.exit_code == 0 assert list_result.exit_code == 0 assert "kis" not in list_result.stdout def test_cli_credential_set_requires_encryption_key() -> None: repo = FakeRepo() repo.create_user("alice", permission="write") runner = build_runner(repo) # CREDENTIAL_ENCRYPTION_KEY 미설정 runner result = runner.invoke( runner.app, [ "credential", "set", "--user", "alice", "--broker", "kis", "--account-ref", "REF", "--app-key", "KEY", "--app-secret", "SECRET", "--force", ], ) assert result.exit_code == 1 assert "CREDENTIAL_ENCRYPTION_KEY" in result.stdout