feat(BAT-005): add broker credential CLI command with persistence and tests
- Add CredentialService for managing broker credentials - Implement credential CLI commands (create, list, get, update, delete) - Add postgres repository support for credential operations - Add comprehensive test coverage for credential CLI - Update docs/batches/index.md and docs/srs/index.md with BAT-005 - Update docs/project-structure.md to reflect credential service
This commit is contained in:
+182
-1
@@ -10,7 +10,7 @@ from typer.testing import CliRunner
|
||||
from app.cli.main import create_app
|
||||
from app.core.config import Settings
|
||||
from app.core.security import hash_token, token_prefix
|
||||
from app.domain.models import AuthToken, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
|
||||
from app.domain.models import AuthToken, BrokerCredential, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
|
||||
|
||||
|
||||
class FakeRepo:
|
||||
@@ -25,6 +25,7 @@ class FakeRepo:
|
||||
self._tags: dict[str, PortfolioTag] = {}
|
||||
self._auth_tokens: dict[str, AuthToken] = {}
|
||||
self._token_seq = 1
|
||||
self._broker_credentials: dict[tuple[str, str], BrokerCredential] = {}
|
||||
self.closed = False
|
||||
|
||||
def close(self) -> None:
|
||||
@@ -429,6 +430,43 @@ class FakeRepo:
|
||||
raise ValueError("댓글을 찾을 수 없습니다")
|
||||
del self._comments[comment_id]
|
||||
|
||||
# --- broker credentials ---
|
||||
|
||||
def upsert_broker_credential(
|
||||
self,
|
||||
user_id: str,
|
||||
broker: str,
|
||||
*,
|
||||
account_ref: str,
|
||||
encrypted_payload: str,
|
||||
) -> BrokerCredential:
|
||||
key = (user_id, broker)
|
||||
existing = self._broker_credentials.get(key)
|
||||
version = (existing.credential_version + 1) if existing else 1
|
||||
credential = BrokerCredential(
|
||||
user_id=user_id,
|
||||
broker=broker,
|
||||
account_ref=account_ref,
|
||||
encrypted_payload=encrypted_payload,
|
||||
credential_version=version,
|
||||
last_rotated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
self._broker_credentials[key] = credential
|
||||
return credential
|
||||
|
||||
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]:
|
||||
return [c for (uid, _), c in self._broker_credentials.items() if uid == user_id]
|
||||
|
||||
def get_broker_credential(self, user_id: str, broker: str) -> BrokerCredential | None:
|
||||
return self._broker_credentials.get((user_id, broker))
|
||||
|
||||
def delete_broker_credential(self, user_id: str, broker: str) -> bool:
|
||||
key = (user_id, broker)
|
||||
if key in self._broker_credentials:
|
||||
del self._broker_credentials[key]
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _settings() -> Settings:
|
||||
return Settings(db_enabled=True, allowed_origins_raw="http://localhost:3000")
|
||||
@@ -1281,3 +1319,146 @@ def test_cli_portfolio_list_in_memory_mode_filters_owner() -> None:
|
||||
|
||||
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