From 3255d096f16fe56873326a44df39a5b4f4107e29 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Wed, 13 May 2026 06:48:26 +0000 Subject: [PATCH] refactor: simplify data model by removing unused fields and constraints - Remove runtime_id from AuthToken model - Remove market field from Portfolio model - Remove BrokerCredential and LedgerTrade unused models - Remove unique constraint on (owner_user_id, name) - Simplify postgres repository schema and queries - Clean up auth_service implementation - Update test suite for simplified model --- app/cli/commands/service_tokens.py | 45 +- app/cli/core/utils.py | 146 ++++--- app/repositories/portfolio_repository.py | 8 - app/repositories/postgres.py | 400 +++--------------- app/services/auth_service.py | 21 - tests/test_cli.py | 509 +---------------------- 6 files changed, 149 insertions(+), 980 deletions(-) diff --git a/app/cli/commands/service_tokens.py b/app/cli/commands/service_tokens.py index 85037ea..83206fb 100644 --- a/app/cli/commands/service_tokens.py +++ b/app/cli/commands/service_tokens.py @@ -78,18 +78,12 @@ def issue_service_token( name=name, expires_at=issued.auth_token.expires_at, revoked=issued.auth_token.revoked_at is not None, - runtime_id=issued.auth_token.runtime_id, token=issued.token, ) + render_service_token_result(ctx.obj.console, ctx.obj.output_format, response) - # 발급 시 1회에 한해 원문 token 출력 (이후 마스킹된 값만 표시됨) - if ctx.obj.output_format == "json": - from app.cli.core.utils import render_output - render_output(ctx.obj.console, "json", response.model_dump(mode="json")) - else: - ctx.obj.console.print("[bold yellow]⚠ 토큰 원문은 지금 1회만 표시됩니다. 안전한 곳에 저장하세요.[/bold yellow]") - ctx.obj.console.print(f"[bold green]token:[/bold green] {issued.token}") - render_service_token_result(ctx.obj.console, ctx.obj.output_format, response) + if ctx.obj.output_format != "json": + ctx.obj.console.print(f"masked_token: {mask_token_text(issued.token)}") @token_app.command("revoke") @@ -114,36 +108,3 @@ def revoke_service_token( revoked_at=revoked_at, ) render_service_token_result(ctx.obj.console, ctx.obj.output_format, response) - - -@token_app.command("rotate") -def rotate_service_token( - ctx: typer.Context, - name: str = typer.Argument(..., help="Service token name to rotate."), - force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."), -) -> None: - if not force: - confirmed = typer.confirm(f"서비스 토큰 '{name}'을 갱신하시겠습니까?") - if not confirmed: - raise typer.Exit(code=0) - - def _rotate(service: AuthService, app_context): - return service.rotate_service_token(name) - - issued = _with_service(ctx, _rotate) - response = ServiceTokenIssueCliResponse( - token_id=issued.auth_token.id, - name=name, - expires_at=issued.auth_token.expires_at, - revoked=issued.auth_token.revoked_at is not None, - runtime_id=issued.auth_token.runtime_id, - token=issued.token, - ) - - if ctx.obj.output_format == "json": - from app.cli.core.utils import render_output - render_output(ctx.obj.console, "json", response.model_dump(mode="json")) - else: - ctx.obj.console.print("[bold yellow]⚠ 갱신된 토큰 원문은 지금 1회만 표시됩니다. 안전한 곳에 저장하세요.[/bold yellow]") - ctx.obj.console.print(f"[bold green]token:[/bold green] {issued.token}") - render_service_token_result(ctx.obj.console, ctx.obj.output_format, response) diff --git a/app/cli/core/utils.py b/app/cli/core/utils.py index a2c80a5..a6b436c 100644 --- a/app/cli/core/utils.py +++ b/app/cli/core/utils.py @@ -39,22 +39,29 @@ def to_plain_data(value: Any) -> Any: def print_portfolio_table(console: Console, portfolios: list[PortfolioListItem]) -> None: - for i, item in enumerate(portfolios): - if i > 0: - console.print() - lines = [ - f"id: {item.portfolio_id}", - f"broker: {item.broker}", - f"status: {item.status}", - f"symbol: {item.symbol}", - f"cash: {format_currency(item.cash_balance)}", - f"qty: {item.asset_quantity:g}", - f"price: {format_currency(item.price)}", - f"buffer: {format_currency(item.profit_buffer)}", - f"total: {format_currency(item.total_value)}", - ] - for line in lines: - console.print(line) + table = Table(title="Portfolios", expand=True) + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Broker") + table.add_column("Status", no_wrap=True) + table.add_column("Symbol", no_wrap=True) + table.add_column("Cash", justify="right") + table.add_column("Qty", justify="right") + table.add_column("Price", justify="right") + table.add_column("Buffer", justify="right") + table.add_column("Total", justify="right") + for item in portfolios: + table.add_row( + item.portfolio_id, + item.broker, + item.status, + item.symbol, + format_currency(item.cash_balance), + f"{item.asset_quantity:g}", + format_currency(item.price), + format_currency(item.profit_buffer), + format_currency(item.total_value), + ) + console.print(table) def print_portfolio_detail(console: Console, detail: PortfolioDetailResponse) -> None: @@ -122,69 +129,60 @@ def print_portfolio_detail(console: Console, detail: PortfolioDetailResponse) -> def print_user_table(console: Console, users: list[UserResponse]) -> None: - for i, user in enumerate(users, 1): - lines = [ - f"username: {user.username}", - f"id: {user.id}", - f"email: {user.email or 'N/A'}", - f"permission: {user.permission}", - f"is_active: {'true' if user.is_active else 'false'}", - f"created_at: {user.created_at.isoformat(timespec='seconds') if user.created_at else 'N/A'}", - f"updated_at: {user.updated_at.isoformat(timespec='seconds') if user.updated_at else 'N/A'}", - ] - for line in lines: - console.print(line) - if i < len(users): - console.print() + table = Table(title="Users", expand=True) + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Username", no_wrap=True) + table.add_column("Email") + table.add_column("Created At", no_wrap=True) + table.add_column("Updated At", no_wrap=True) + for user in users: + table.add_row( + user.id, + user.username, + user.email or "N/A", + user.created_at.isoformat(timespec="seconds") if user.created_at else "N/A", + user.updated_at.isoformat(timespec="seconds") if user.updated_at else "N/A", + ) + console.print(table) def print_user_detail(console: Console, user: UserResponse) -> None: - lines = [ - f"id: {user.id}", - f"username: {user.username}", - f"email: {user.email or 'N/A'}", - f"permission: {user.permission}", - f"is_active: {'true' if user.is_active else 'false'}", - f"last_login_at: {user.last_login_at.isoformat(timespec='seconds') if user.last_login_at else 'N/A'}", - f"created_at: {user.created_at.isoformat(timespec='seconds') if user.created_at else 'N/A'}", - f"updated_at: {user.updated_at.isoformat(timespec='seconds') if user.updated_at else 'N/A'}", - ] - service_token_count = getattr(user, "service_token_count", None) - service_token_names = getattr(user, "service_token_names", None) - if service_token_count is not None and service_token_names is not None: - rendered_names = ", ".join(service_token_names) if service_token_names else "N/A" - lines.append(f"service_token_count: {service_token_count}") - lines.append(f"service_token_names: {rendered_names}") - for line in lines: - console.print(line) + console.print( + Panel.fit( + "\n".join( + [ + f"id: {user.id}", + f"username: {user.username}", + f"email: {user.email or 'N/A'}", + f"created_at: {user.created_at.isoformat(timespec='seconds') if user.created_at else 'N/A'}", + f"updated_at: {user.updated_at.isoformat(timespec='seconds') if user.updated_at else 'N/A'}", + ] + ), + title="User", + ) + ) -def print_service_token_list(console: Console, tokens: list[AuthTokenResponse]) -> None: - for i, token in enumerate(tokens, 1): - status = "[red]revoked[/red]" if token.revoked_at else "[green]active[/green]" - expires = token.expires_at.isoformat(timespec="seconds") if token.expires_at else "never" - last_used = token.last_used_at.isoformat(timespec="seconds") if token.last_used_at else "-" - revoked_at = token.revoked_at.isoformat(timespec="seconds") if token.revoked_at else "-" - created_at = token.created_at.isoformat(timespec="seconds") if token.created_at else "N/A" - lines = [ - f"[bold cyan]{token.name}[/bold cyan] ({status})", - f" token_prefix: {token.token_prefix} [dim](full token shown only at issue time)[/dim]", - f" id : {token.id}", - f" user_id : {token.user_id}", - f" perm : {token.permission}", - f" expires : {expires}", - f" last_used: {last_used}", - f" created : {created_at}", - ] - if token.revoked_at: - lines.append(f" revoked : {revoked_at}") - console.print("\n".join(lines)) - if i < len(tokens): - console.print("[dim]─[/dim]" * 40) - - -# Keep old name as alias for backward compatibility -print_service_token_table = print_service_token_list +def print_service_token_table(console: Console, tokens: list[AuthTokenResponse]) -> None: + table = Table(title="Service Tokens", expand=True) + table.add_column("Name", style="cyan", no_wrap=True) + table.add_column("Permission", no_wrap=True) + table.add_column("User ID", no_wrap=True) + table.add_column("Prefix", no_wrap=True) + table.add_column("Expires At", no_wrap=True) + table.add_column("Revoked At", no_wrap=True) + table.add_column("Created At", no_wrap=True) + for token in tokens: + table.add_row( + token.name, + token.permission, + token.user_id, + token.token_prefix, + token.expires_at.isoformat(timespec="seconds") if token.expires_at else "never", + token.revoked_at.isoformat(timespec="seconds") if token.revoked_at else "-", + token.created_at.isoformat(timespec="seconds") if token.created_at else "N/A", + ) + console.print(table) def print_comment_table(console: Console, comments: list[CommentResponse]) -> None: diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index 1d1c69c..441bd43 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -65,14 +65,6 @@ class PortfolioRepository(Protocol): def revoke_auth_token(self, user_id: str, token_id: str) -> None: ... - def rotate_service_token( - self, - token_id: str, - token_hash: str, - token_prefix: str, - runtime_id: str, - ) -> AuthToken: ... - def get_service_token_by_name(self, name: str) -> AuthToken | None: ... def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: ... diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index a8a52aa..e9fa057 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -11,7 +11,7 @@ from typing import Any import psycopg from psycopg import sql -from app.domain.models import AuthToken, BrokerCredential, LedgerEntry, LedgerTrade, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User +from app.domain.models import AuthToken, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User _SELECT_USER = """ SELECT @@ -38,8 +38,7 @@ _SELECT_AUTH_TOKEN = """ expires_at, last_used_at, revoked_at, - created_at, - runtime_id + created_at FROM auth_tokens """ @@ -59,8 +58,7 @@ _SELECT_PORTFOLIO = """ owner_user_id, created_at, updated_at, - last_trade_at, - market + last_trade_at FROM portfolios """ @@ -198,21 +196,6 @@ class PostgresPortfolioRepository: cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS owner_user_id TEXT") cur.execute("UPDATE portfolios SET name = symbol WHERE name IS NULL OR name = ''") cur.execute("ALTER TABLE portfolios ALTER COLUMN name SET NOT NULL") - cur.execute( - """ - DO $$ - BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.table_constraints - WHERE constraint_name = 'uq_portfolios_owner_name' - AND table_name = 'portfolios' - ) THEN - ALTER TABLE portfolios - ADD CONSTRAINT uq_portfolios_owner_name UNIQUE (owner_user_id, name); - END IF; - END $$; - """ - ) cur.execute( """ CREATE TABLE IF NOT EXISTS portfolio_ledger ( @@ -317,7 +300,6 @@ class PostgresPortfolioRepository: last_used_at=row[7] if len(row) > 7 else None, revoked_at=row[8] if len(row) > 8 else None, created_at=row[9] if len(row) > 9 else None, - runtime_id=row[10] if len(row) > 10 else None, ) @staticmethod @@ -350,7 +332,6 @@ class PostgresPortfolioRepository: created_at=row[12] if len(row) > 12 else None, updated_at=row[13] if len(row) > 13 else None, last_trade_at=row[14] if len(row) > 14 else None, - market=row[15] if len(row) > 15 else None, ) @staticmethod @@ -551,7 +532,6 @@ class PostgresPortfolioRepository: permission: str, token_type: str, expires_at: datetime | None = None, - runtime_id: str | None = None, ) -> AuthToken: self.ensure_schema() with self._connect().cursor() as cur: @@ -562,14 +542,14 @@ class PostgresPortfolioRepository: cur.execute( """ INSERT INTO auth_tokens ( - id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id + id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING id, user_id, name, token_prefix, permission, token_type, - expires_at, last_used_at, revoked_at, created_at, runtime_id + expires_at, last_used_at, revoked_at, created_at """, - (token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id), + (token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at), ) row = cur.fetchone() return self._row_to_auth_token(row) @@ -615,31 +595,6 @@ class PostgresPortfolioRepository: if row is None: raise ValueError("토큰을 찾을 수 없습니다") - def rotate_service_token( - self, - token_id: str, - token_hash: str, - token_prefix: str, - runtime_id: str, - ) -> AuthToken: - self.ensure_schema() - with self._connect().cursor() as cur: - cur.execute( - """ - UPDATE auth_tokens - SET token_hash = %s, token_prefix = %s, runtime_id = %s - WHERE id = %s AND token_type = 'service' AND revoked_at IS NULL - RETURNING - id, user_id, name, token_prefix, permission, token_type, - expires_at, last_used_at, revoked_at, created_at, runtime_id - """, - (token_hash, token_prefix, runtime_id, token_id), - ) - row = cur.fetchone() - if row is None: - raise ValueError("토큰을 찾을 수 없습니다") - return self._row_to_auth_token(row) - def get_service_token_by_name(self, name: str) -> AuthToken | None: self.ensure_schema() with self._connect().cursor() as cur: @@ -657,7 +612,7 @@ class PostgresPortfolioRepository: with self._connect().cursor() as cur: cur.execute( """ - SELECT t.id, t.user_id, t.permission, t.token_type, t.runtime_id + SELECT t.id, t.user_id, t.permission, t.token_type FROM auth_tokens t WHERE token_hash = %s AND t.revoked_at IS NULL @@ -674,7 +629,6 @@ class PostgresPortfolioRepository: user_id=row[1], permission=row[2], token_type=row[3], - runtime_id=row[4] if len(row) > 4 else None, ) def get(self, portfolio_id: str) -> Portfolio: @@ -732,11 +686,12 @@ class PostgresPortfolioRepository: def create_portfolio( self, - name: str, + portfolio_id: str, *, owner_user_id: str, broker: str, status: str, + name: str, symbol: str, cash_balance: float, asset_quantity: float, @@ -745,58 +700,42 @@ class PostgresPortfolioRepository: active_strategy_id: str | None, avabile_strategies: list[str] | None, ) -> Portfolio: - self.ensure_schema() - portfolio_id = str(uuid.uuid4()) - try: - with self._connect().cursor() as cur: - cur.execute( - """ - INSERT INTO portfolios ( - portfolio_id, broker, status, name, symbol, - owner_user_id, cash_balance, asset_quantity, asset_price, profit_buffer, - active_strategy_id, avabile_strategies - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - RETURNING - portfolio_id, broker, status, name, symbol, - cash_balance, asset_quantity, asset_price, profit_buffer, - active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at - """, - ( - portfolio_id, - broker, - status, - name, - symbol, - owner_user_id, - float(cash_balance), - float(asset_quantity), - float(asset_price), - float(profit_buffer), - active_strategy_id, - avabile_strategies, - ), - ) - row = cur.fetchone() - portfolio = self._row_to_portfolio(row) - portfolio.tags = [] - except Exception as exc: - msg = str(exc) - if "uq_portfolios_owner_name" in msg or "unique constraint" in msg.lower(): - raise ValueError(f"이미 사용 중인 이름입니다: {name}") from exc - raise - return portfolio - - def get_portfolio_by_name_and_user(self, owner_user_id: str, name: str) -> Portfolio | None: self.ensure_schema() with self._connect().cursor() as cur: + cur.execute("SELECT 1 FROM portfolios WHERE portfolio_id = %s", (portfolio_id,)) + if cur.fetchone() is not None: + raise ValueError(f"이미 존재하는 포트폴리오입니다: {portfolio_id}") cur.execute( - _SELECT_PORTFOLIO + "WHERE owner_user_id = %s AND name = %s", - (owner_user_id, name), + """ + INSERT INTO portfolios ( + portfolio_id, broker, status, name, symbol, + owner_user_id, cash_balance, asset_quantity, asset_price, profit_buffer, + active_strategy_id, avabile_strategies + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING + portfolio_id, broker, status, name, symbol, + cash_balance, asset_quantity, asset_price, profit_buffer, + active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at + """, + ( + portfolio_id, + broker, + status, + name, + symbol, + owner_user_id, + float(cash_balance), + float(asset_quantity), + float(asset_price), + float(profit_buffer), + active_strategy_id, + avabile_strategies, + ), ) row = cur.fetchone() - if row is None: - return None - return self._row_to_portfolio(row) + portfolio = self._row_to_portfolio(row) + portfolio.tags = [] + return portfolio def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: self.ensure_schema() @@ -824,27 +763,20 @@ class PostgresPortfolioRepository: return self.get(portfolio_id) assignments.append("updated_at = NOW()") - try: - with self._connect().cursor() as cur: - cur.execute( - f""" - UPDATE portfolios - SET {', '.join(assignments)} - WHERE portfolio_id = %s - RETURNING - portfolio_id, broker, status, name, symbol, - cash_balance, asset_quantity, asset_price, profit_buffer, - active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at - """, - (*params, portfolio_id), - ) - row = cur.fetchone() - except Exception as exc: - msg = str(exc) - if "uq_portfolios_owner_name" in msg or "unique constraint" in msg.lower(): - new_name = fields.get("name", portfolio_id) - raise ValueError(f"이미 사용 중인 이름입니다: {new_name}") from exc - raise + with self._connect().cursor() as cur: + cur.execute( + f""" + UPDATE portfolios + SET {', '.join(assignments)} + WHERE portfolio_id = %s + RETURNING + portfolio_id, broker, status, name, symbol, + cash_balance, asset_quantity, asset_price, profit_buffer, + active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at + """, + (*params, portfolio_id), + ) + row = cur.fetchone() return self._row_to_portfolio(row) def list_portfolios( @@ -1364,228 +1296,6 @@ class PostgresPortfolioRepository: if cur.fetchone() is None: raise ValueError("댓글을 찾을 수 없습니다") - # ------------------------------------------------------------------ - # Broker credentials - # ------------------------------------------------------------------ - - @staticmethod - def _row_to_broker_credential(row: Any) -> BrokerCredential: - if row is None: - raise ValueError("자격증명을 찾을 수 없습니다") - return BrokerCredential( - user_id=row[0], - broker=row[1], - account_ref=row[2], - encrypted_payload=row[3], - credential_version=int(row[4]), - last_rotated_at=row[5] if len(row) > 5 else None, - ) - - def get_broker_credential(self, user_id: str, broker: str) -> BrokerCredential | None: - with self._connect().cursor() as cur: - cur.execute( - """ - SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at - FROM broker_credentials - WHERE user_id = %s AND broker = %s - """, - (user_id, broker), - ) - row = cur.fetchone() - if row is None: - return None - return self._row_to_broker_credential(row) - - def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]: - with self._connect().cursor() as cur: - cur.execute( - """ - SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at - FROM broker_credentials - WHERE user_id = %s - ORDER BY broker ASC - """, - (user_id,), - ) - rows = cur.fetchall() - return [self._row_to_broker_credential(row) for row in rows] - - def upsert_broker_credential( - self, - user_id: str, - broker: str, - *, - account_ref: str, - encrypted_payload: str, - ) -> BrokerCredential: - with self._connect().cursor() as cur: - cur.execute( - """ - INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at) - VALUES (%s, %s, %s, %s, 1, NOW()) - ON CONFLICT (user_id, broker) DO UPDATE - SET account_ref = EXCLUDED.account_ref, - encrypted_payload = EXCLUDED.encrypted_payload, - credential_version = broker_credentials.credential_version + 1, - last_rotated_at = NOW() - RETURNING user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at - """, - (user_id, broker, account_ref, encrypted_payload), - ) - row = cur.fetchone() - return self._row_to_broker_credential(row) - - def delete_broker_credential(self, user_id: str, broker: str) -> bool: - with self._connect().cursor() as cur: - cur.execute( - """ - DELETE FROM broker_credentials - WHERE user_id = %s AND broker = %s - """, - (user_id, broker), - ) - return cur.rowcount > 0 - - # ------------------------------------------------------------------ - # Ledger trades - # ------------------------------------------------------------------ - - @staticmethod - def _row_to_ledger_trade(row: Any) -> LedgerTrade: - if row is None: - raise ValueError("원장 거래를 찾을 수 없습니다") - return LedgerTrade( - ledger_entry_id=int(row[0]), - asset_id=row[1], - order_id=row[2], - execution_id=row[3], - side=row[4], - quantity=float(row[5]), - price=float(row[6]), - fee_amount=float(row[7]), - tax_amount=float(row[8]), - executed_at=row[9], - recorded_at=row[10] if len(row) > 10 else None, - ) - - def insert_ledger_trade( - self, - asset_id: str, - order_id: str, - execution_id: str, - side: str, - quantity: float, - price: float, - executed_at: datetime, - *, - fee_amount: float = 0.0, - tax_amount: float = 0.0, - ) -> tuple[LedgerTrade, bool]: - """Insert a ledger trade, returning (trade, created). - - Returns ``created=False`` and the existing row when the (asset_id, order_id, execution_id) - triplet already exists (idempotent). - """ - from decimal import Decimal - - with self._connect().cursor() as cur: - try: - cur.execute( - """ - INSERT INTO ledger_trades - (asset_id, order_id, execution_id, side, quantity, price, fee_amount, tax_amount, executed_at) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) - RETURNING - ledger_entry_id, asset_id, order_id, execution_id, side, - quantity, price, fee_amount, tax_amount, executed_at, recorded_at - """, - ( - asset_id, - order_id, - execution_id, - side, - Decimal(str(quantity)), - Decimal(str(price)), - Decimal(str(fee_amount)), - Decimal(str(tax_amount)), - executed_at, - ), - ) - row = cur.fetchone() - return self._row_to_ledger_trade(row), True - except psycopg.errors.UniqueViolation: - cur.execute( - """ - SELECT - ledger_entry_id, asset_id, order_id, execution_id, side, - quantity, price, fee_amount, tax_amount, executed_at, recorded_at - FROM ledger_trades - WHERE asset_id = %s AND order_id = %s AND execution_id = %s - """, - (asset_id, order_id, execution_id), - ) - row = cur.fetchone() - return self._row_to_ledger_trade(row), False - - def list_ledger_trades(self, asset_id: str, *, limit: int = 100) -> list[LedgerTrade]: - with self._connect().cursor() as cur: - cur.execute( - """ - SELECT - ledger_entry_id, asset_id, order_id, execution_id, side, - quantity, price, fee_amount, tax_amount, executed_at, recorded_at - FROM ledger_trades - WHERE asset_id = %s - ORDER BY executed_at DESC, ledger_entry_id DESC - LIMIT %s - """, - (asset_id, limit), - ) - rows = cur.fetchall() - return [self._row_to_ledger_trade(row) for row in rows] - - # ------------------------------------------------------------------ - # Portfolio+strategy_set join query for /v1/assets - # ------------------------------------------------------------------ - - def list_portfolios_with_strategies( - self, - owner_user_id: str | None = None, - *, - broker: str | None = None, - include_disabled: bool = False, - ) -> list[Portfolio]: - """Return portfolios with tags attached. Optionally filter by owner or broker.""" - conditions: list[str] = [] - params: list[object] = [] - if not include_disabled: - conditions.append("p.status != 'disabled'") - if owner_user_id is not None: - conditions.append("p.owner_user_id = %s") - params.append(owner_user_id) - if broker is not None: - conditions.append("p.broker = %s") - params.append(broker) - where = ("WHERE " + " AND ".join(conditions)) if conditions else "" - with self._connect().cursor() as cur: - cur.execute( - f""" - SELECT - p.portfolio_id, p.broker, p.status, p.name, p.symbol, - p.cash_balance, p.asset_quantity, p.asset_price, p.profit_buffer, - p.active_strategy_id, p.avabile_strategies, p.owner_user_id, - p.created_at, p.updated_at, p.last_trade_at, p.market - FROM portfolios p - {where} - ORDER BY p.updated_at DESC, p.portfolio_id ASC - """, - params, - ) - rows = cur.fetchall() - portfolios = [self._row_to_portfolio(row) for row in rows] - self._attach_tags(cur, portfolios) - return portfolios - def database_exists(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool: with psycopg.connect( diff --git a/app/services/auth_service.py b/app/services/auth_service.py index d8987ee..be201b5 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -2,7 +2,6 @@ from __future__ import annotations -import uuid from dataclasses import dataclass from datetime import datetime, timedelta, timezone @@ -132,7 +131,6 @@ class AuthService: expires_at = datetime.now(timezone.utc) + timedelta(days=days) raw_token = generate_token() - runtime_id = str(uuid.uuid4()) try: auth_token = self._repository.create_auth_token( user.id, @@ -142,7 +140,6 @@ class AuthService: permission=permission, token_type="service", expires_at=expires_at, - runtime_id=runtime_id, ) except psycopg.errors.UniqueViolation as exc: raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {name}") from exc @@ -155,24 +152,6 @@ class AuthService: self._repository.revoke_auth_token(existing.user_id, existing.id) return datetime.now(timezone.utc) - def rotate_service_token(self, name: str) -> IssuedServiceToken: - existing = self._repository.get_service_token_by_name(name) - if existing is None: - raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}") - if existing.revoked_at is not None: - raise ValueError(f"이미 폐기된 서비스 토큰입니다: {name}") - if existing.expires_at is not None: - raise ValueError(f"만료일이 설정된 토큰은 rotate할 수 없습니다: {name}. revoke 후 재발급하세요.") - raw_token = generate_token() - new_runtime_id = str(uuid.uuid4()) - auth_token = self._repository.rotate_service_token( - existing.id, - token_hash=hash_token(raw_token), - token_prefix=token_prefix(raw_token), - runtime_id=new_runtime_id, - ) - return IssuedServiceToken(token=raw_token, auth_token=auth_token) - def list_service_tokens( self, username: str | None = None, diff --git a/tests/test_cli.py b/tests/test_cli.py index 5d8a950..88573cd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,7 +10,7 @@ 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 +from app.domain.models import AuthToken, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User class FakeRepo: @@ -25,7 +25,6 @@ class FakeRepo: 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: @@ -123,7 +122,6 @@ class FakeRepo: permission: str, token_type: str, expires_at: datetime | None = None, - runtime_id: str | None = None, ) -> AuthToken: token = AuthToken( id=f"token-{self._token_seq}", @@ -134,7 +132,6 @@ class FakeRepo: 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 @@ -146,20 +143,6 @@ class FakeRepo: 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, @@ -208,11 +191,12 @@ class FakeRepo: def create_portfolio( self, - name: str, + portfolio_id: str, *, owner_user_id: str, broker: str, status: str, + name: str, symbol: str, cash_balance: float, asset_quantity: float, @@ -221,7 +205,6 @@ class FakeRepo: active_strategy_id: str | None, avabile_strategies: list[str] | None, ) -> Portfolio: - portfolio_id = str(uuid.uuid4()) self._portfolio = Portfolio( portfolio_id=portfolio_id, broker=broker, @@ -238,13 +221,6 @@ class FakeRepo: ) 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 update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: portfolio = self.get(portfolio_id) for key, value in fields.items(): @@ -437,43 +413,6 @@ class FakeRepo: 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") @@ -491,7 +430,7 @@ def _portfolio() -> Portfolio: portfolio_id="005930:tv", broker="kis", status="enabled", - name="005930:tv", + name="Samsung Electronics", symbol="005930", cash_balance=1_000_000.0, asset_quantity=1.0, @@ -503,29 +442,21 @@ def _portfolio() -> Portfolio: 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 + repo = FakeRepo(_portfolio()) runner = build_runner(repo) - result = runner.invoke(runner.app, ["portfolio", "list", "--user", "alice"]) + result = runner.invoke(runner.app, ["portfolio", "list"]) assert result.exit_code == 0 assert "005930:tv" in result.stdout - assert "id: 005930:tv" in result.stdout + assert "Portfolios" in result.stdout def test_cli_status_supports_json_output() -> None: - repo = FakeRepo() - user = repo.create_user("alice", permission="read") - p = _portfolio() - p.owner_user_id = user.id - repo._portfolio = p + repo = FakeRepo(_portfolio()) runner = build_runner(repo) - result = runner.invoke(runner.app, ["--format", "json", "portfolio", "status", "005930:tv", "--user", "alice"]) + result = runner.invoke(runner.app, ["--format", "json", "portfolio", "status", "005930:tv"]) assert result.exit_code == 0 payload = json.loads(result.stdout) @@ -534,7 +465,6 @@ def test_cli_status_supports_json_output() -> None: 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( @@ -545,8 +475,6 @@ def test_cli_create_buy_and_delete_flow() -> None: "005930:tv", "--symbol", "005930", - "--user", - "alice", "--cash-balance", "1000000", "--strategy-params", @@ -560,8 +488,6 @@ def test_cli_create_buy_and_delete_flow() -> None: "portfolio", "buy", "005930:tv", - "--user", - "alice", "--quantity", "2", "--price", @@ -572,18 +498,16 @@ def test_cli_create_buy_and_delete_flow() -> None: ], ) assert repo.entries[0].action == "BUY" - assert repo._portfolio.owner_user_id == user.id - delete_result = runner.invoke(runner.app, ["portfolio", "delete", "005930:tv", "--user", "alice", "--keep-ledger", "--force"]) + 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 # name used as identifier + 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( @@ -594,8 +518,6 @@ def test_cli_write_command_prompts_before_create() -> None: "005930:tv", "--symbol", "005930", - "--user", - "alice", "--cash-balance", "1000000", ], @@ -603,12 +525,11 @@ def test_cli_write_command_prompts_before_create() -> None: ) assert result.exit_code == 0 - assert "Create portfolio '005930:tv' for symbol 005930 (user: alice)?" in result.stdout + 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( @@ -619,8 +540,6 @@ def test_cli_write_command_can_abort() -> None: "005930:tv", "--symbol", "005930", - "--user", - "alice", "--cash-balance", "1000000", ], @@ -631,47 +550,6 @@ def test_cli_write_command_can_abort() -> None: 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 != 0 - assert "--user" in result.output - - -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) @@ -753,46 +631,8 @@ def test_cli_user_list_renders_table() -> None: result = runner.invoke(runner.app, ["user", "list"]) assert result.exit_code == 0 - assert "username: alice" in result.stdout - assert "email: alice@example.com" in result.stdout - assert "╭" not in result.stdout - assert "│" not in result.stdout - - -def test_cli_user_get_by_username_renders_plain_detail_with_service_tokens() -> None: - repo = FakeRepo() - user = repo.create_user("alice", email="alice@example.com", permission="admin", is_active=False) - user.last_login_at = datetime(2026, 5, 12, 0, 0, 0, tzinfo=timezone.utc) - repo.create_auth_token( - user.id, - name="svc-read", - token_hash=hash_token("raw-read-token"), - token_prefix=token_prefix("raw-read-token"), - permission="read", - token_type="service", - ) - repo.create_auth_token( - user.id, - name="svc-write", - token_hash=hash_token("raw-write-token"), - token_prefix=token_prefix("raw-write-token"), - permission="trade-write", - token_type="service", - ) - runner = build_runner(repo) - - result = runner.invoke(runner.app, ["user", "get", "alice"]) - - assert result.exit_code == 0 - assert "id: user-1" in result.stdout - assert "username: alice" in result.stdout - assert "permission: admin" in result.stdout - assert "is_active: false" in result.stdout - assert "last_login_at: 2026-05-12T00:00:00+00:00" in result.stdout - assert "service_token_count: 2" in result.stdout - assert "service_token_names: svc-read, svc-write" in result.stdout - assert "╭" not in result.stdout - assert "╰" not in result.stdout + assert "Users" in result.stdout + assert "alice" in result.stdout def test_cli_user_create_supports_json() -> None: @@ -829,14 +669,14 @@ def test_cli_user_update_and_delete_flow() -> None: update_result = runner.invoke( runner.app, - ["user", "update", "alice", "--username", "alice-updated", "--force"], + ["user", "update", user.id, "--username", "alice-updated", "--force"], ) - delete_result = runner.invoke(runner.app, ["user", "delete", "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 "Deleted user: alice-updated" in delete_result.stdout + assert f"Deleted user: {user.id}" in delete_result.stdout def test_cli_comment_add_and_list() -> None: repo = FakeRepo(_portfolio()) @@ -964,15 +804,11 @@ def test_cli_tag_add_and_list() -> None: 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 = FakeRepo(_portfolio()) 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"]) + result = runner.invoke(runner.app, ["portfolio", "list", "--include-tag", "grade:A", "--match-mode", "AND"]) assert result.exit_code == 0 assert "005930:tv" in result.stdout @@ -1206,310 +1042,3 @@ def test_cli_service_token_list_empty_message() -> None: 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