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
This commit is contained in:
@@ -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)
|
||||
|
||||
+72
-74
@@ -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:
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
+55
-345
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
+19
-490
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user