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 Asset, AuthToken, BrokerCredential, Daemon, 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._daemons: dict[str, Daemon] = {} self._user_seq = 1 self._daemon_seq = 1 self._comments: dict[str, PortfolioComment] = {} self._tags: dict[str, PortfolioTag] = {} self._auth_tokens: dict[str, AuthToken] = {} self._broker_credentials: dict[tuple[str, str], BrokerCredential] = {} self._token_seq = 1 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 sorted( self._auth_tokens.values(), key=lambda token: token.created_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True, ) if t.name == name and t.token_type == "service" and t.revoked_at is None ), None, ) def create_daemon( self, owner_user_id: str, *, daemon_slug: str, display_name: str | None = None, ) -> Daemon: daemon = Daemon( id=f"daemon-{self._daemon_seq}", owner_user_id=owner_user_id, daemon_slug=daemon_slug, display_name=display_name, is_active=True, created_at=datetime.now(timezone.utc), ) self._daemon_seq += 1 self._daemons[daemon.id] = daemon return daemon def get_daemon_by_id(self, daemon_id: str) -> Daemon | None: return self._daemons.get(daemon_id) def list_daemons_for_user(self, owner_user_id: str) -> list[Daemon]: return [daemon for daemon in self._daemons.values() if daemon.owner_user_id == owner_user_id] def update_daemon( self, daemon_id: str, *, display_name: str | None = None, is_active: bool | None = None, ) -> Daemon: daemon = self._daemons.get(daemon_id) if daemon is None: raise ValueError("데몬을 찾을 수 없습니다") if display_name is not None: daemon.display_name = display_name if is_active is not None: daemon.is_active = is_active daemon.updated_at = datetime.now(timezone.utc) return daemon def upsert_broker_credential( self, user_id: str, broker: str, *, account_ref: str, encrypted_payload: str, daemon_id: str | None = None, ) -> BrokerCredential: self.get_user(user_id) key = (user_id, broker) existing = self._broker_credentials.get(key) credential = BrokerCredential( user_id=user_id, broker=broker, account_ref=account_ref, encrypted_payload=encrypted_payload, credential_version=(existing.credential_version + 1) if existing is not None else 1, last_rotated_at=datetime.now(timezone.utc), daemon_id=daemon_id, ) self._broker_credentials[key] = credential return credential def list_broker_credentials( self, user_id: str, daemon_id: str | None = None, ) -> list[BrokerCredential]: creds = [ credential for (owner_user_id, _), credential in self._broker_credentials.items() if owner_user_id == user_id ] if daemon_id is not None: creds = [c for c in creds if c.daemon_id == daemon_id] return sorted(creds, key=lambda credential: credential.broker) def update_broker_credential_daemon_id( self, user_id: str, broker: str, daemon_id: str, ) -> bool: key = (user_id, broker) cred = self._broker_credentials.get(key) if cred is None or cred.daemon_id is not None: return False self._broker_credentials[key] = BrokerCredential( user_id=cred.user_id, broker=cred.broker, account_ref=cred.account_ref, encrypted_payload=cred.encrypted_payload, credential_version=cred.credential_version, last_rotated_at=cred.last_rotated_at, daemon_id=daemon_id, ) return True def get_daemon_by_slug(self, owner_user_id: str, daemon_slug: str): return next( (d for d in self._daemons.values() if d.owner_user_id == owner_user_id and d.daemon_slug == daemon_slug), None, ) def delete_broker_credential( self, user_id: str, broker: str, daemon_id: str | None = None, ) -> bool: if daemon_id is not None: # daemon_id 기반 삭제 for key, cred in list(self._broker_credentials.items()): if cred.daemon_id == daemon_id and cred.broker == broker: del self._broker_credentials[key] return True return False key = (user_id, broker) if key not in self._broker_credentials: return False del self._broker_credentials[key] return True 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, daemon_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, daemon_id=daemon_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 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 rebind_service_token(self, name: str, daemon_id: str) -> AuthToken: token = self.get_service_token_by_name(name) if token is None: raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}") token.daemon_id = daemon_id return token 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_portfolio_by_name_and_user(self, owner_user_id: str, name: str) -> Portfolio | None: if self._portfolio is None: return None if self._portfolio.owner_user_id == owner_user_id and self._portfolio.name == name: return self._portfolio return None 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, asset_id: 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=asset_id, asset_id=asset_id, 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 get_asset_by_id(self, asset_id: str): return Asset(asset_id=asset_id, symbol="TEST", markets=[], status="active") def get_asset_by_symbol(self, symbol: str): fake_id = "00000000-0000-0000-0000-000000000001" return Asset(asset_id=fake_id, symbol=symbol, markets=[], status="active") def list_assets(self): return [] def insert_asset(self, *, symbol: str, markets: list, display_name=None, status: str = "active"): raise NotImplementedError() def update_asset(self, asset_id: str, **kwargs): raise NotImplementedError() 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, *, include_inactive: bool = True) -> list[StrategySet]: from datetime import datetime, timezone values = list(self._strategy_sets.values()) if not include_inactive: values = [item for item in values if item.status == "active"] return sorted(values, key=lambda s: s.created_at or datetime.now(timezone.utc), reverse=True) def update_strategy_set( self, id: str, *, name: str | None = None, config: str | None = None, status: str | None = None, ) -> StrategySet: strategy = self._strategy_sets.get(id) if strategy is None: raise ValueError("전략을 찾을 수 없습니다") final_name = name if name is not None else strategy.name final_config = config if config is not None else strategy.config final_status = status if status is not None else strategy.status if (final_name, final_config, final_status) == ( strategy.name, strategy.config, strategy.status, ): return strategy strategy.name = final_name strategy.config = final_config strategy.status = final_status strategy.nick = f"{strategy.name}:updated" strategy.revision += 1 strategy.updated_at = datetime.now(timezone.utc) return strategy 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] def _settings() -> Settings: return Settings( db_enabled=True, allowed_origins_raw="http://localhost:3000", credential_encryption_key="7ApeKhDwGrxiPdlfNbWw2pylyU-XvTk4v3CiRDnNdV0=", ) def build_runner(repo: FakeRepo) -> CliRunner: app = create_app(settings_factory=_settings, repository_factory=lambda settings: repo) runner = CliRunner() runner.app = app return runner _TEST_ASSET_ID = "00000000-0000-0000-0000-000000000001" def _portfolio(owner_user_id: str = "user-1") -> Portfolio: return Portfolio( portfolio_id="005930:tv", broker="kis", status="enabled", name="Samsung_Electronics", symbol="005930", asset_id=_TEST_ASSET_ID, owner_user_id=owner_user_id, 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 _repo_with_user(portfolio: Portfolio | None = None) -> tuple["FakeRepo", "User"]: repo = FakeRepo(portfolio) user = repo.create_user("alice") if portfolio is not None: portfolio.owner_user_id = user.id return repo, user def test_cli_list_renders_table() -> None: repo, user = _repo_with_user(_portfolio()) runner = build_runner(repo) result = runner.invoke(runner.app, ["portfolio", "list", "--user", "alice"]) assert result.exit_code == 0 assert "Samsung_Electronics" in result.stdout assert "005930" in result.stdout def test_cli_status_supports_json_output() -> None: repo, user = _repo_with_user(_portfolio()) runner = build_runner(repo) result = runner.invoke(runner.app, ["--format", "json", "portfolio", "status", "Samsung_Electronics", "--user", "alice"]) 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, user = _repo_with_user() runner = build_runner(repo) create_result = runner.invoke( runner.app, [ "portfolio", "create", "Samsung_Electronics", "--user", "alice", "--asset-id", _TEST_ASSET_ID, "--cash-balance", "1000000", "--strategy-id", "set-1", "--force", ], ) buy_result = runner.invoke( runner.app, [ "portfolio", "buy", "Samsung_Electronics", "--user", "alice", "--quantity", "2", "--price", "50000", "--fee", "100", "--force", ], ) assert repo.entries[0].action == "BUY" delete_result = runner.invoke( runner.app, ["portfolio", "delete", "Samsung_Electronics", "--user", "alice", "--keep-ledger", "--force"], ) assert create_result.exit_code == 0 assert buy_result.exit_code == 0 assert delete_result.exit_code == 0 def test_cli_write_command_prompts_before_create() -> None: repo, user = _repo_with_user() runner = build_runner(repo) result = runner.invoke( runner.app, [ "portfolio", "create", "Samsung_Electronics", "--user", "alice", "--asset-id", _TEST_ASSET_ID, "--cash-balance", "1000000", "--strategy-id", "set-1", ], input="y\n", ) assert result.exit_code == 0 assert "Create portfolio" in result.stdout def test_cli_write_command_can_abort() -> None: repo, user = _repo_with_user() runner = build_runner(repo) result = runner.invoke( runner.app, [ "portfolio", "create", "Samsung_Electronics", "--user", "alice", "--asset-id", _TEST_ASSET_ID, "--cash-balance", "1000000", "--strategy-id", "set-1", ], input="n\n", ) assert result.exit_code == 0 assert repo._portfolio is None def test_cli_health_check_reports_repository(monkeypatch) -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) monkeypatch.setattr(health_commands, "current_revision", lambda _settings: "20260814_0029") monkeypatch.setattr(health_commands, "head_revision", lambda _settings: "20260814_0029") result = runner.invoke(runner.app, ["health-check"]) assert result.exit_code == 0 assert "repository: FakeRepo" in result.stdout assert "database_revision: 20260814_0029" in result.stdout def test_cli_health_check_rejects_database_behind_packaged_head(monkeypatch) -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) monkeypatch.setattr(health_commands, "current_revision", lambda _settings: "20260715_0028") monkeypatch.setattr(health_commands, "head_revision", lambda _settings: "20260814_0029") result = runner.invoke(runner.app, ["health-check"]) assert result.exit_code == 1 assert "is not packaged head" 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 "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", "alice", "--username", "alice-updated", "--force"], ) delete_result = runner.invoke(runner.app, ["user", "delete", "alice-updated", "--force"]) assert update_result.exit_code == 0 assert "alice-updated" in update_result.stdout assert delete_result.exit_code == 0 assert "Deleted user: alice-updated" 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_list_supports_tag_filters() -> None: repo, user = _repo_with_user(_portfolio()) repo.add_tag("005930:tv", tag="grade:A") runner = build_runner(repo) result = runner.invoke(runner.app, ["portfolio", "list", "--user", "alice", "--include-tag", "grade:A", "--match-mode", "AND"]) assert result.exit_code == 0 assert "Samsung_Electronics" 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 "rebind" in result.stdout assert "revoke" in result.stdout def test_cli_daemon_create_and_list_render_entries() -> None: repo = FakeRepo(_portfolio()) repo.create_user("alice", permission="read") runner = build_runner(repo) create_result = runner.invoke( runner.app, ["daemon", "create", "alpha-bot", "--user", "alice", "--display-name", "Alpha Bot"], ) list_result = runner.invoke( runner.app, ["daemon", "list", "--user", "alice"], ) assert create_result.exit_code == 0 assert "Daemon" in create_result.stdout assert "alpha-bot" in create_result.stdout assert "Alpha Bot" in create_result.stdout assert list_result.exit_code == 0 assert "Daemons" in list_result.stdout assert "alpha-bot" in list_result.stdout assert "Alpha Bot" in list_result.stdout def test_cli_credential_set_list_and_remove_flow() -> None: """REQ-050: --daemon-id 포함 credential set/list/remove 플로우 (BAT-020 갱신).""" repo = FakeRepo(_portfolio()) user = repo.create_user("alice", permission="read") daemon = repo.create_daemon(user.id, daemon_slug="orb-bot") runner = build_runner(repo) set_result = runner.invoke( runner.app, [ "--format", "json", "credential", "set", "--user", "alice", "--daemon-id", "orb-bot", "--broker", "kis", "--account-ref", "123-45", "--app-key", "key-1", "--app-secret", "secret-1", "--force", ], ) list_result = runner.invoke( runner.app, ["--format", "json", "credential", "list", "--user", "alice"], ) remove_result = runner.invoke( runner.app, ["--format", "json", "credential", "remove", "--user", "alice", "--broker", "kis", "--force"], ) assert set_result.exit_code == 0 set_payload = json.loads(set_result.stdout) assert set_payload["user_id"] == user.id assert set_payload["broker"] == "kis" assert set_payload["daemon_id"] == daemon.id assert set_payload["credential_version"] == 1 assert list_result.exit_code == 0 list_payload = json.loads(list_result.stdout) assert len(list_payload) == 1 assert list_payload[0]["broker"] == "kis" assert list_payload[0]["account_ref"] == "123-45" assert list_payload[0]["daemon_id"] == daemon.id assert list_payload[0]["credential_version"] == 1 assert remove_result.exit_code == 0 remove_payload = json.loads(remove_result.stdout) assert remove_payload["deleted"] is True assert repo._broker_credentials == {} 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 assert payload["runtime_id"] is not None # HOTFIX-001 SC-1: JSON 출력의 token 필드는 마스킹되지 않은 평문이어야 한다. assert "*" not in payload["token"] stored = repo.get_service_token_by_name("nightly") assert payload["token"].startswith(stored.token_prefix) def test_render_service_token_result_does_not_mask_token() -> None: """HOTFIX-001 SC-1/SC-2: render_service_token_result는 token을 평문으로 렌더링한다. issue/rotate 모두 동일하게 render_service_token_result를 호출하므로 본 단위 테스트가 두 경로(SC-1 issue, SC-2 rotate)의 렌더링 동작을 함께 커버한다. """ import io from rich.console import Console from app.cli.core.output import render_service_token_result from app.schemas.auth import ServiceTokenIssueCliResponse plaintext = "ySusw9hQ2kPlAiNtExTtOkEnVaLuENotMaskedHere_Wa3w" response = ServiceTokenIssueCliResponse( token_id="540e005a-104b-451a-b11c-0eb03968b958", name="admin", expires_at=None, revoked=False, runtime_id="c544ab21-ce00-41cd-8327-5bf9103ebad8", daemon_id=None, token=plaintext, ) for fmt in ("json", "table"): buffer = io.StringIO() console = Console(file=buffer, force_terminal=False, width=200) render_service_token_result(console, fmt, response) output = buffer.getvalue() assert plaintext in output, f"{fmt}: 평문 토큰이 출력에 없음" assert "*" not in output, f"{fmt}: 마스킹 문자가 출력에 포함됨" def test_cli_service_token_issue_table_shows_reveal_warning() -> None: """HOTFIX-001 SC-3: table 출력은 평문 토큰 + 1회 노출 경고를 표기한다.""" repo = FakeRepo(_portfolio()) repo.create_user("alice", permission="read") runner = build_runner(repo) result = runner.invoke( runner.app, ["service-token", "issue", "nightly", "--user", "alice"], ) assert result.exit_code == 0 stored = repo.get_service_token_by_name("nightly") assert stored.token_prefix in result.stdout assert "다시 표시되지 않습니다" in result.stdout 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") daemon = repo.create_daemon(user.id, daemon_slug="alpha-bot") 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", daemon_id=daemon.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 isinstance(payload, list) assert len(payload) == 1 assert payload[0]["name"] == "active-token" assert payload[0]["daemon_id"] == daemon.id def test_cli_service_token_list_table_shows_daemon_id() -> None: repo = FakeRepo(_portfolio()) user = repo.create_user("alice", permission="read") daemon = repo.create_daemon(user.id, daemon_slug="alpha-bot") repo.create_auth_token( user.id, name="active-token", token_hash=hash_token("tok-table"), token_prefix=token_prefix("tok-table"), permission="read", token_type="service", daemon_id=daemon.id, ) runner = build_runner(repo) result = runner.invoke(runner.app, ["service-token", "list"]) assert result.exit_code == 0 assert "Daemon ID" in result.stdout assert daemon.id in result.stdout 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 def test_cli_service_token_rebind_updates_daemon_id() -> None: repo = FakeRepo(_portfolio()) user = repo.create_user("alice", permission="read") source_daemon = repo.create_daemon(user.id, daemon_slug="source-bot") target_daemon = repo.create_daemon(user.id, daemon_slug="target-bot") token = repo.create_auth_token( user.id, name="bound-token", token_hash=hash_token("tok-rb"), token_prefix=token_prefix("tok-rb"), permission="read", token_type="service", runtime_id=str(uuid.uuid4()), daemon_id=source_daemon.id, ) runner = build_runner(repo) result = runner.invoke( runner.app, ["--format", "json", "service-token", "rebind", "bound-token", "--daemon-id", target_daemon.id], ) assert result.exit_code == 0 payload = json.loads(result.stdout) assert payload["name"] == "bound-token" assert payload["previous_daemon_id"] == source_daemon.id assert payload["daemon_id"] == target_daemon.id assert repo._auth_tokens[token.id].daemon_id == target_daemon.id def test_cli_service_token_rebind_rejects_cross_user_daemon() -> None: repo = FakeRepo(_portfolio()) alice = repo.create_user("alice", permission="read") bob = repo.create_user("bob", permission="read") alice_daemon = repo.create_daemon(alice.id, daemon_slug="alice-bot") bob_daemon = repo.create_daemon(bob.id, daemon_slug="bob-bot") repo.create_auth_token( alice.id, name="bound-token", token_hash=hash_token("tok-rb-2"), token_prefix=token_prefix("tok-rb-2"), permission="read", token_type="service", runtime_id=str(uuid.uuid4()), daemon_id=alice_daemon.id, ) runner = build_runner(repo) result = runner.invoke( runner.app, ["service-token", "rebind", "bound-token", "--daemon-id", bob_daemon.id], ) assert result.exit_code == 1 assert "해당 토큰 사용자에 속하지 않습니다" in result.stdout def test_cli_strategy_commands_show_status_revision_and_preserve_id() -> None: repo = FakeRepo() runner = build_runner(repo) listed = runner.invoke(runner.app, ["strategy", "list"]) updated = runner.invoke( runner.app, ["strategy", "update", "set-1", "--status", "inactive", "--name", "target_value_v2"], ) no_change = runner.invoke(runner.app, ["strategy", "update", "set-1", "--status", "inactive"]) assert listed.exit_code == 0 assert "Status" in listed.output and "Revision" in listed.output assert updated.exit_code == 0 assert "ID: set-1" in updated.output assert "Status: inactive" in updated.output assert "Revision: 2" in updated.output assert no_change.exit_code == 0 assert "Revision: 2" in no_change.output def test_cli_strategy_update_requires_a_field() -> None: repo = FakeRepo() runner = build_runner(repo) result = runner.invoke(runner.app, ["strategy", "update", "set-1"]) assert result.exit_code == 1 assert "at least one" in result.output