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,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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user