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:
2026-05-13 06:48:26 +00:00
parent 03bc3dcd8f
commit 3255d096f1
6 changed files with 149 additions and 980 deletions
+3 -42
View File
@@ -78,19 +78,13 @@ def issue_service_token(
name=name, name=name,
expires_at=issued.auth_token.expires_at, expires_at=issued.auth_token.expires_at,
revoked=issued.auth_token.revoked_at is not None, revoked=issued.auth_token.revoked_at is not None,
runtime_id=issued.auth_token.runtime_id,
token=issued.token, token=issued.token,
) )
# 발급 시 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) 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") @token_app.command("revoke")
def revoke_service_token( def revoke_service_token(
@@ -114,36 +108,3 @@ def revoke_service_token(
revoked_at=revoked_at, revoked_at=revoked_at,
) )
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response) 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)
+66 -68
View File
@@ -39,22 +39,29 @@ def to_plain_data(value: Any) -> Any:
def print_portfolio_table(console: Console, portfolios: list[PortfolioListItem]) -> None: def print_portfolio_table(console: Console, portfolios: list[PortfolioListItem]) -> None:
for i, item in enumerate(portfolios): table = Table(title="Portfolios", expand=True)
if i > 0: table.add_column("ID", style="cyan", no_wrap=True)
console.print() table.add_column("Broker")
lines = [ table.add_column("Status", no_wrap=True)
f"id: {item.portfolio_id}", table.add_column("Symbol", no_wrap=True)
f"broker: {item.broker}", table.add_column("Cash", justify="right")
f"status: {item.status}", table.add_column("Qty", justify="right")
f"symbol: {item.symbol}", table.add_column("Price", justify="right")
f"cash: {format_currency(item.cash_balance)}", table.add_column("Buffer", justify="right")
f"qty: {item.asset_quantity:g}", table.add_column("Total", justify="right")
f"price: {format_currency(item.price)}", for item in portfolios:
f"buffer: {format_currency(item.profit_buffer)}", table.add_row(
f"total: {format_currency(item.total_value)}", item.portfolio_id,
] item.broker,
for line in lines: item.status,
console.print(line) 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: 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: def print_user_table(console: Console, users: list[UserResponse]) -> None:
for i, user in enumerate(users, 1): table = Table(title="Users", expand=True)
lines = [ table.add_column("ID", style="cyan", no_wrap=True)
f"username: {user.username}", table.add_column("Username", no_wrap=True)
f"id: {user.id}", table.add_column("Email")
f"email: {user.email or 'N/A'}", table.add_column("Created At", no_wrap=True)
f"permission: {user.permission}", table.add_column("Updated At", no_wrap=True)
f"is_active: {'true' if user.is_active else 'false'}", for user in users:
f"created_at: {user.created_at.isoformat(timespec='seconds') if user.created_at else 'N/A'}", table.add_row(
f"updated_at: {user.updated_at.isoformat(timespec='seconds') if user.updated_at else 'N/A'}", user.id,
] user.username,
for line in lines: user.email or "N/A",
console.print(line) user.created_at.isoformat(timespec="seconds") if user.created_at else "N/A",
if i < len(users): user.updated_at.isoformat(timespec="seconds") if user.updated_at else "N/A",
console.print() )
console.print(table)
def print_user_detail(console: Console, user: UserResponse) -> None: def print_user_detail(console: Console, user: UserResponse) -> None:
lines = [ console.print(
Panel.fit(
"\n".join(
[
f"id: {user.id}", f"id: {user.id}",
f"username: {user.username}", f"username: {user.username}",
f"email: {user.email or 'N/A'}", 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"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'}", 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) title="User",
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)
def print_service_token_list(console: Console, tokens: list[AuthTokenResponse]) -> None: def print_service_token_table(console: Console, tokens: list[AuthTokenResponse]) -> None:
for i, token in enumerate(tokens, 1): table = Table(title="Service Tokens", expand=True)
status = "[red]revoked[/red]" if token.revoked_at else "[green]active[/green]" table.add_column("Name", style="cyan", no_wrap=True)
expires = token.expires_at.isoformat(timespec="seconds") if token.expires_at else "never" table.add_column("Permission", no_wrap=True)
last_used = token.last_used_at.isoformat(timespec="seconds") if token.last_used_at else "-" table.add_column("User ID", no_wrap=True)
revoked_at = token.revoked_at.isoformat(timespec="seconds") if token.revoked_at else "-" table.add_column("Prefix", no_wrap=True)
created_at = token.created_at.isoformat(timespec="seconds") if token.created_at else "N/A" table.add_column("Expires At", no_wrap=True)
lines = [ table.add_column("Revoked At", no_wrap=True)
f"[bold cyan]{token.name}[/bold cyan] ({status})", table.add_column("Created At", no_wrap=True)
f" token_prefix: {token.token_prefix} [dim](full token shown only at issue time)[/dim]", for token in tokens:
f" id : {token.id}", table.add_row(
f" user_id : {token.user_id}", token.name,
f" perm : {token.permission}", token.permission,
f" expires : {expires}", token.user_id,
f" last_used: {last_used}", token.token_prefix,
f" created : {created_at}", token.expires_at.isoformat(timespec="seconds") if token.expires_at else "never",
] token.revoked_at.isoformat(timespec="seconds") if token.revoked_at else "-",
if token.revoked_at: token.created_at.isoformat(timespec="seconds") if token.created_at else "N/A",
lines.append(f" revoked : {revoked_at}") )
console.print("\n".join(lines)) console.print(table)
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_comment_table(console: Console, comments: list[CommentResponse]) -> None: def print_comment_table(console: Console, comments: list[CommentResponse]) -> None:
-8
View File
@@ -65,14 +65,6 @@ class PortfolioRepository(Protocol):
def revoke_auth_token(self, user_id: str, token_id: str) -> None: ... 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_service_token_by_name(self, name: str) -> AuthToken | None: ...
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: ... def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: ...
+13 -303
View File
@@ -11,7 +11,7 @@ from typing import Any
import psycopg import psycopg
from psycopg import sql 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_USER = """
SELECT SELECT
@@ -38,8 +38,7 @@ _SELECT_AUTH_TOKEN = """
expires_at, expires_at,
last_used_at, last_used_at,
revoked_at, revoked_at,
created_at, created_at
runtime_id
FROM auth_tokens FROM auth_tokens
""" """
@@ -59,8 +58,7 @@ _SELECT_PORTFOLIO = """
owner_user_id, owner_user_id,
created_at, created_at,
updated_at, updated_at,
last_trade_at, last_trade_at
market
FROM portfolios FROM portfolios
""" """
@@ -198,21 +196,6 @@ class PostgresPortfolioRepository:
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS owner_user_id TEXT") 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("UPDATE portfolios SET name = symbol WHERE name IS NULL OR name = ''")
cur.execute("ALTER TABLE portfolios ALTER COLUMN name SET NOT NULL") 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( cur.execute(
""" """
CREATE TABLE IF NOT EXISTS portfolio_ledger ( CREATE TABLE IF NOT EXISTS portfolio_ledger (
@@ -317,7 +300,6 @@ class PostgresPortfolioRepository:
last_used_at=row[7] if len(row) > 7 else None, last_used_at=row[7] if len(row) > 7 else None,
revoked_at=row[8] if len(row) > 8 else None, revoked_at=row[8] if len(row) > 8 else None,
created_at=row[9] if len(row) > 9 else None, created_at=row[9] if len(row) > 9 else None,
runtime_id=row[10] if len(row) > 10 else None,
) )
@staticmethod @staticmethod
@@ -350,7 +332,6 @@ class PostgresPortfolioRepository:
created_at=row[12] if len(row) > 12 else None, created_at=row[12] if len(row) > 12 else None,
updated_at=row[13] if len(row) > 13 else None, updated_at=row[13] if len(row) > 13 else None,
last_trade_at=row[14] if len(row) > 14 else None, last_trade_at=row[14] if len(row) > 14 else None,
market=row[15] if len(row) > 15 else None,
) )
@staticmethod @staticmethod
@@ -551,7 +532,6 @@ class PostgresPortfolioRepository:
permission: str, permission: str,
token_type: str, token_type: str,
expires_at: datetime | None = None, expires_at: datetime | None = None,
runtime_id: str | None = None,
) -> AuthToken: ) -> AuthToken:
self.ensure_schema() self.ensure_schema()
with self._connect().cursor() as cur: with self._connect().cursor() as cur:
@@ -562,14 +542,14 @@ class PostgresPortfolioRepository:
cur.execute( cur.execute(
""" """
INSERT INTO auth_tokens ( 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 RETURNING
id, user_id, name, token_prefix, permission, token_type, 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() row = cur.fetchone()
return self._row_to_auth_token(row) return self._row_to_auth_token(row)
@@ -615,31 +595,6 @@ class PostgresPortfolioRepository:
if row is None: if row is None:
raise ValueError("토큰을 찾을 수 없습니다") 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: def get_service_token_by_name(self, name: str) -> AuthToken | None:
self.ensure_schema() self.ensure_schema()
with self._connect().cursor() as cur: with self._connect().cursor() as cur:
@@ -657,7 +612,7 @@ class PostgresPortfolioRepository:
with self._connect().cursor() as cur: with self._connect().cursor() as cur:
cur.execute( 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 FROM auth_tokens t
WHERE token_hash = %s WHERE token_hash = %s
AND t.revoked_at IS NULL AND t.revoked_at IS NULL
@@ -674,7 +629,6 @@ class PostgresPortfolioRepository:
user_id=row[1], user_id=row[1],
permission=row[2], permission=row[2],
token_type=row[3], token_type=row[3],
runtime_id=row[4] if len(row) > 4 else None,
) )
def get(self, portfolio_id: str) -> Portfolio: def get(self, portfolio_id: str) -> Portfolio:
@@ -732,11 +686,12 @@ class PostgresPortfolioRepository:
def create_portfolio( def create_portfolio(
self, self,
name: str, portfolio_id: str,
*, *,
owner_user_id: str, owner_user_id: str,
broker: str, broker: str,
status: str, status: str,
name: str,
symbol: str, symbol: str,
cash_balance: float, cash_balance: float,
asset_quantity: float, asset_quantity: float,
@@ -746,9 +701,10 @@ class PostgresPortfolioRepository:
avabile_strategies: list[str] | None, avabile_strategies: list[str] | None,
) -> Portfolio: ) -> Portfolio:
self.ensure_schema() self.ensure_schema()
portfolio_id = str(uuid.uuid4())
try:
with self._connect().cursor() as cur: 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( cur.execute(
""" """
INSERT INTO portfolios ( INSERT INTO portfolios (
@@ -779,25 +735,8 @@ class PostgresPortfolioRepository:
row = cur.fetchone() row = cur.fetchone()
portfolio = self._row_to_portfolio(row) portfolio = self._row_to_portfolio(row)
portfolio.tags = [] 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 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_PORTFOLIO + "WHERE owner_user_id = %s AND name = %s",
(owner_user_id, name),
)
row = cur.fetchone()
if row is None:
return None
return self._row_to_portfolio(row)
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio:
self.ensure_schema() self.ensure_schema()
allowed = { allowed = {
@@ -824,7 +763,6 @@ class PostgresPortfolioRepository:
return self.get(portfolio_id) return self.get(portfolio_id)
assignments.append("updated_at = NOW()") assignments.append("updated_at = NOW()")
try:
with self._connect().cursor() as cur: with self._connect().cursor() as cur:
cur.execute( cur.execute(
f""" f"""
@@ -839,12 +777,6 @@ class PostgresPortfolioRepository:
(*params, portfolio_id), (*params, portfolio_id),
) )
row = cur.fetchone() 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
return self._row_to_portfolio(row) return self._row_to_portfolio(row)
def list_portfolios( def list_portfolios(
@@ -1364,228 +1296,6 @@ class PostgresPortfolioRepository:
if cur.fetchone() is None: if cur.fetchone() is None:
raise ValueError("댓글을 찾을 수 없습니다") 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: def database_exists(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool:
with psycopg.connect( with psycopg.connect(
-21
View File
@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -132,7 +131,6 @@ class AuthService:
expires_at = datetime.now(timezone.utc) + timedelta(days=days) expires_at = datetime.now(timezone.utc) + timedelta(days=days)
raw_token = generate_token() raw_token = generate_token()
runtime_id = str(uuid.uuid4())
try: try:
auth_token = self._repository.create_auth_token( auth_token = self._repository.create_auth_token(
user.id, user.id,
@@ -142,7 +140,6 @@ class AuthService:
permission=permission, permission=permission,
token_type="service", token_type="service",
expires_at=expires_at, expires_at=expires_at,
runtime_id=runtime_id,
) )
except psycopg.errors.UniqueViolation as exc: except psycopg.errors.UniqueViolation as exc:
raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {name}") from exc raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {name}") from exc
@@ -155,24 +152,6 @@ class AuthService:
self._repository.revoke_auth_token(existing.user_id, existing.id) self._repository.revoke_auth_token(existing.user_id, existing.id)
return datetime.now(timezone.utc) 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( def list_service_tokens(
self, self,
username: str | None = None, username: str | None = None,
+19 -490
View File
@@ -10,7 +10,7 @@ from typer.testing import CliRunner
from app.cli.main import create_app from app.cli.main import create_app
from app.core.config import Settings from app.core.config import Settings
from app.core.security import hash_token, token_prefix 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: class FakeRepo:
@@ -25,7 +25,6 @@ class FakeRepo:
self._tags: dict[str, PortfolioTag] = {} self._tags: dict[str, PortfolioTag] = {}
self._auth_tokens: dict[str, AuthToken] = {} self._auth_tokens: dict[str, AuthToken] = {}
self._token_seq = 1 self._token_seq = 1
self._broker_credentials: dict[tuple[str, str], BrokerCredential] = {}
self.closed = False self.closed = False
def close(self) -> None: def close(self) -> None:
@@ -123,7 +122,6 @@ class FakeRepo:
permission: str, permission: str,
token_type: str, token_type: str,
expires_at: datetime | None = None, expires_at: datetime | None = None,
runtime_id: str | None = None,
) -> AuthToken: ) -> AuthToken:
token = AuthToken( token = AuthToken(
id=f"token-{self._token_seq}", id=f"token-{self._token_seq}",
@@ -134,7 +132,6 @@ class FakeRepo:
token_type=token_type, token_type=token_type,
expires_at=expires_at, expires_at=expires_at,
created_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc),
runtime_id=runtime_id,
) )
self._token_seq += 1 self._token_seq += 1
self._auth_tokens[token.id] = token self._auth_tokens[token.id] = token
@@ -146,20 +143,6 @@ class FakeRepo:
raise ValueError("토큰을 찾을 수 없습니다") raise ValueError("토큰을 찾을 수 없습니다")
token.revoked_at = datetime.now(timezone.utc) 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( def list_service_tokens(
self, self,
user_id: str | None = None, user_id: str | None = None,
@@ -208,11 +191,12 @@ class FakeRepo:
def create_portfolio( def create_portfolio(
self, self,
name: str, portfolio_id: str,
*, *,
owner_user_id: str, owner_user_id: str,
broker: str, broker: str,
status: str, status: str,
name: str,
symbol: str, symbol: str,
cash_balance: float, cash_balance: float,
asset_quantity: float, asset_quantity: float,
@@ -221,7 +205,6 @@ class FakeRepo:
active_strategy_id: str | None, active_strategy_id: str | None,
avabile_strategies: list[str] | None, avabile_strategies: list[str] | None,
) -> Portfolio: ) -> Portfolio:
portfolio_id = str(uuid.uuid4())
self._portfolio = Portfolio( self._portfolio = Portfolio(
portfolio_id=portfolio_id, portfolio_id=portfolio_id,
broker=broker, broker=broker,
@@ -238,13 +221,6 @@ class FakeRepo:
) )
return self._portfolio 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: def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio:
portfolio = self.get(portfolio_id) portfolio = self.get(portfolio_id)
for key, value in fields.items(): for key, value in fields.items():
@@ -437,43 +413,6 @@ class FakeRepo:
raise ValueError("댓글을 찾을 수 없습니다") raise ValueError("댓글을 찾을 수 없습니다")
del self._comments[comment_id] 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: def _settings() -> Settings:
return Settings(db_enabled=True, allowed_origins_raw="http://localhost:3000") return Settings(db_enabled=True, allowed_origins_raw="http://localhost:3000")
@@ -491,7 +430,7 @@ def _portfolio() -> Portfolio:
portfolio_id="005930:tv", portfolio_id="005930:tv",
broker="kis", broker="kis",
status="enabled", status="enabled",
name="005930:tv", name="Samsung Electronics",
symbol="005930", symbol="005930",
cash_balance=1_000_000.0, cash_balance=1_000_000.0,
asset_quantity=1.0, asset_quantity=1.0,
@@ -503,29 +442,21 @@ def _portfolio() -> Portfolio:
def test_cli_list_renders_table() -> None: def test_cli_list_renders_table() -> None:
repo = FakeRepo() repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
p = _portfolio()
p.owner_user_id = user.id
repo._portfolio = p
runner = build_runner(repo) 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 result.exit_code == 0
assert "005930:tv" in result.stdout 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: def test_cli_status_supports_json_output() -> None:
repo = FakeRepo() repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
p = _portfolio()
p.owner_user_id = user.id
repo._portfolio = p
runner = build_runner(repo) 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 assert result.exit_code == 0
payload = json.loads(result.stdout) 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: def test_cli_create_buy_and_delete_flow() -> None:
repo = FakeRepo() repo = FakeRepo()
user = repo.create_user("alice", permission="read")
runner = build_runner(repo) runner = build_runner(repo)
create_result = runner.invoke( create_result = runner.invoke(
@@ -545,8 +475,6 @@ def test_cli_create_buy_and_delete_flow() -> None:
"005930:tv", "005930:tv",
"--symbol", "--symbol",
"005930", "005930",
"--user",
"alice",
"--cash-balance", "--cash-balance",
"1000000", "1000000",
"--strategy-params", "--strategy-params",
@@ -560,8 +488,6 @@ def test_cli_create_buy_and_delete_flow() -> None:
"portfolio", "portfolio",
"buy", "buy",
"005930:tv", "005930:tv",
"--user",
"alice",
"--quantity", "--quantity",
"2", "2",
"--price", "--price",
@@ -572,18 +498,16 @@ def test_cli_create_buy_and_delete_flow() -> None:
], ],
) )
assert repo.entries[0].action == "BUY" assert repo.entries[0].action == "BUY"
assert repo._portfolio.owner_user_id == user.id delete_result = runner.invoke(runner.app, ["portfolio", "delete", "005930:tv", "--keep-ledger", "--force"])
delete_result = runner.invoke(runner.app, ["portfolio", "delete", "005930:tv", "--user", "alice", "--keep-ledger", "--force"])
assert create_result.exit_code == 0 assert create_result.exit_code == 0
assert buy_result.exit_code == 0 assert buy_result.exit_code == 0
assert delete_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: def test_cli_write_command_prompts_before_create() -> None:
repo = FakeRepo() repo = FakeRepo()
repo.create_user("alice", permission="read")
runner = build_runner(repo) runner = build_runner(repo)
result = runner.invoke( result = runner.invoke(
@@ -594,8 +518,6 @@ def test_cli_write_command_prompts_before_create() -> None:
"005930:tv", "005930:tv",
"--symbol", "--symbol",
"005930", "005930",
"--user",
"alice",
"--cash-balance", "--cash-balance",
"1000000", "1000000",
], ],
@@ -603,12 +525,11 @@ def test_cli_write_command_prompts_before_create() -> None:
) )
assert result.exit_code == 0 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: def test_cli_write_command_can_abort() -> None:
repo = FakeRepo() repo = FakeRepo()
repo.create_user("alice", permission="read")
runner = build_runner(repo) runner = build_runner(repo)
result = runner.invoke( result = runner.invoke(
@@ -619,8 +540,6 @@ def test_cli_write_command_can_abort() -> None:
"005930:tv", "005930:tv",
"--symbol", "--symbol",
"005930", "005930",
"--user",
"alice",
"--cash-balance", "--cash-balance",
"1000000", "1000000",
], ],
@@ -631,47 +550,6 @@ def test_cli_write_command_can_abort() -> None:
assert repo._portfolio is 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: def test_cli_health_check_reports_repository() -> None:
repo = FakeRepo(_portfolio()) repo = FakeRepo(_portfolio())
runner = build_runner(repo) runner = build_runner(repo)
@@ -753,46 +631,8 @@ def test_cli_user_list_renders_table() -> None:
result = runner.invoke(runner.app, ["user", "list"]) result = runner.invoke(runner.app, ["user", "list"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "username: alice" in result.stdout assert "Users" in result.stdout
assert "email: alice@example.com" in result.stdout assert "alice" 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
def test_cli_user_create_supports_json() -> None: 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( update_result = runner.invoke(
runner.app, 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 update_result.exit_code == 0
assert "alice-updated" in update_result.stdout assert "alice-updated" in update_result.stdout
assert delete_result.exit_code == 0 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: def test_cli_comment_add_and_list() -> None:
repo = FakeRepo(_portfolio()) repo = FakeRepo(_portfolio())
@@ -964,15 +804,11 @@ def test_cli_tag_add_and_list() -> None:
def test_cli_list_supports_tag_filters() -> None: def test_cli_list_supports_tag_filters() -> None:
repo = FakeRepo() repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
p = _portfolio()
p.owner_user_id = user.id
repo._portfolio = p
repo.add_tag("005930:tv", tag="grade:A") repo.add_tag("005930:tv", tag="grade:A")
runner = build_runner(repo) 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 result.exit_code == 0
assert "005930:tv" in result.stdout 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 result.exit_code == 0
assert "No service tokens found." in result.stdout 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