feat: implement broker credential repository with upsert and list operations

- Add BrokerCredential to domain models and repositories
- Implement upsert_broker_credential() for credential storage and rotation
- Implement list_broker_credentials() for credential retrieval
- Implement delete_broker_credential() for credential removal
- Add broker_credentials table schema with (user_id, broker) composite key
- Implement credential version tracking and last_rotated_at timestamps
- Add FakeRepo support for broker credential CRUD operations
- Add comprehensive test coverage for broker credential lifecycle
This commit is contained in:
2026-05-14 07:56:37 +00:00
parent f4663f202e
commit d194873616
3 changed files with 181 additions and 4 deletions
+99 -2
View File
@@ -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, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
from app.domain.models import AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
class FakeRepo:
@@ -26,6 +26,7 @@ class FakeRepo:
self._comments: dict[str, PortfolioComment] = {}
self._tags: dict[str, PortfolioTag] = {}
self._auth_tokens: dict[str, AuthToken] = {}
self._broker_credentials: dict[tuple[str, str], BrokerCredential] = {}
self._token_seq = 1
self.closed = False
@@ -167,6 +168,41 @@ class FakeRepo:
daemon.updated_at = datetime.now(timezone.utc)
return daemon
def upsert_broker_credential(
self,
user_id: str,
broker: str,
*,
account_ref: str,
encrypted_payload: str,
) -> BrokerCredential:
self.get_user(user_id)
key = (user_id, broker)
existing = self._broker_credentials.get(key)
credential = BrokerCredential(
user_id=user_id,
broker=broker,
account_ref=account_ref,
encrypted_payload=encrypted_payload,
credential_version=(existing.credential_version + 1) if existing is not None else 1,
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 sorted(
[credential for (owner_user_id, _), credential in self._broker_credentials.items() if owner_user_id == user_id],
key=lambda credential: credential.broker,
)
def delete_broker_credential(self, user_id: str, broker: str) -> bool:
key = (user_id, broker)
if key not in self._broker_credentials:
return False
del self._broker_credentials[key]
return True
def create_auth_token(
self,
user_id: str,
@@ -481,7 +517,11 @@ class FakeRepo:
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",
credential_encryption_key="7ApeKhDwGrxiPdlfNbWw2pylyU-XvTk4v3CiRDnNdV0=",
)
def build_runner(repo: FakeRepo) -> CliRunner:
@@ -928,6 +968,63 @@ def test_cli_daemon_create_and_list_render_entries() -> None:
assert "Alpha Bot" in list_result.stdout
def test_cli_credential_set_list_and_remove_flow() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
runner = build_runner(repo)
set_result = runner.invoke(
runner.app,
[
"--format",
"json",
"credential",
"set",
"--user",
"alice",
"--broker",
"kis",
"--account-ref",
"123-45",
"--app-key",
"key-1",
"--app-secret",
"secret-1",
"--force",
],
)
list_result = runner.invoke(
runner.app,
["--format", "json", "credential", "list", "--user", "alice"],
)
remove_result = runner.invoke(
runner.app,
["--format", "json", "credential", "remove", "--user", "alice", "--broker", "kis", "--force"],
)
assert set_result.exit_code == 0
set_payload = json.loads(set_result.stdout)
assert set_payload["user_id"] == user.id
assert set_payload["broker"] == "kis"
assert set_payload["credential_version"] == 1
assert list_result.exit_code == 0
list_payload = json.loads(list_result.stdout)
assert list_payload == [
{
"broker": "kis",
"account_ref": "123-45",
"credential_version": 1,
"last_rotated_at": list_payload[0]["last_rotated_at"],
}
]
assert remove_result.exit_code == 0
remove_payload = json.loads(remove_result.stdout)
assert remove_payload["deleted"] is True
assert repo._broker_credentials == {}
def test_cli_service_token_issue_supports_json() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")