1516 lines
49 KiB
Python
1516 lines
49 KiB
Python
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
|