feat(bat-022): daemon CRUD admin API + self-service credential management (REQ-052/053/054)
- POST/PATCH/deactivate/activate /v1/admin/daemons — admin-only daemon CRUD (REQ-053)
- GET/PUT/DELETE /v1/daemons/{daemon_id}/credential — user-owned scoped credential management (REQ-052)
- Owner re-assignment auto-invalidates BrokerCredential via update_daemon_owner() (REQ-053 AC-4)
- Deactivation auto-revokes service tokens via revoke_tokens_by_daemon_id() (REQ-022 reuse, REQ-053 AC-6)
- DELETE /v1/admin/daemons/{id} → 405 (hard delete not provided, REQ-053 AC-8)
- All responses exclude app_key/app_secret; Fernet encryption at rest (REQ-054)
- Service-token → 403 on credential endpoints (REQ-052 AC-10)
- Admin ownership scope enforced: admin cannot write other user's credential (REQ-052 AC-9)
- REQ-051 regression: 4 service-token guard tests pass (REQ-054 AC-7)
- 44 new tests, 565 total passed (521 baseline + 44 new), 0 failures
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
@@ -0,0 +1,516 @@
|
||||
"""BAT-022: REQ-052 — GET/PUT/DELETE /v1/daemons/{daemon_id}/credential (소유 스코프 인증정보 관리)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.auth import AuthContext
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.encryption import encrypt_payload
|
||||
from app.domain.models import BrokerCredential, Daemon, TokenPrincipal, User
|
||||
from app.main import create_app
|
||||
|
||||
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
|
||||
OWNER_USER_ID = "user-owner"
|
||||
OTHER_USER_ID = "user-other"
|
||||
DAEMON_ID = "daemon-001"
|
||||
BROKER = "kis"
|
||||
ACCOUNT_REF = "acc-001"
|
||||
APP_KEY = "my-app-key-12345"
|
||||
APP_SECRET = "my-app-secret-xyz"
|
||||
|
||||
|
||||
def _make_settings(**kwargs) -> Settings:
|
||||
return Settings(
|
||||
allowed_origins_raw="http://localhost:3000",
|
||||
db_enabled=True,
|
||||
credential_encryption_key=_TEST_ENC_KEY,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _make_client(settings: Settings, mock_repo) -> TestClient:
|
||||
get_settings.cache_clear()
|
||||
app = create_app(settings_factory=lambda: settings)
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _auth_header(token: str = "user-token") -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _make_daemon(
|
||||
daemon_id: str = DAEMON_ID,
|
||||
owner_user_id: str = OWNER_USER_ID,
|
||||
is_active: bool = True,
|
||||
) -> Daemon:
|
||||
return Daemon(
|
||||
id=daemon_id,
|
||||
owner_user_id=owner_user_id,
|
||||
daemon_slug="trex-orb",
|
||||
display_name="ORB",
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
|
||||
def _make_cred(
|
||||
daemon_id: str = DAEMON_ID,
|
||||
account_ref: str = ACCOUNT_REF,
|
||||
credential_version: int = 1,
|
||||
) -> BrokerCredential:
|
||||
payload_json = json.dumps({"app_key": APP_KEY, "app_secret": APP_SECRET})
|
||||
encrypted = encrypt_payload(_TEST_ENC_KEY, payload_json)
|
||||
return BrokerCredential(
|
||||
user_id=OWNER_USER_ID,
|
||||
broker=BROKER,
|
||||
account_ref=account_ref,
|
||||
encrypted_payload=encrypted,
|
||||
credential_version=credential_version,
|
||||
last_rotated_at=datetime(2026, 6, 28, 12, 0, 0, tzinfo=timezone.utc),
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
|
||||
|
||||
def _make_user_principal(user_id: str = OWNER_USER_ID) -> TokenPrincipal:
|
||||
return TokenPrincipal(
|
||||
token_id="tok-1",
|
||||
user_id=user_id,
|
||||
permission="read",
|
||||
token_type="user",
|
||||
runtime_id=None,
|
||||
daemon_id=None,
|
||||
)
|
||||
|
||||
|
||||
def _make_service_principal(user_id: str = OWNER_USER_ID) -> TokenPrincipal:
|
||||
return TokenPrincipal(
|
||||
token_id="svc-1",
|
||||
user_id=user_id,
|
||||
permission="read",
|
||||
token_type="service",
|
||||
runtime_id="rt-1",
|
||||
daemon_id=DAEMON_ID,
|
||||
)
|
||||
|
||||
|
||||
def _make_admin_principal(user_id: str = "admin-user") -> TokenPrincipal:
|
||||
return TokenPrincipal(
|
||||
token_id="adm-1",
|
||||
user_id=user_id,
|
||||
permission="admin",
|
||||
token_type="admin",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-052 AC-1: PUT → 200 + 메타데이터 (secret 미포함)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_put_credential_returns_meta_without_secret() -> None:
|
||||
"""REQ-052 AC-1: user-token(소유자) PUT → 200 + 메타데이터, app_key/app_secret 없음."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
cred = _make_cred(credential_version=1)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.upsert_broker_credential.return_value = cred
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": ACCOUNT_REF, "broker": BROKER},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
# Metadata fields present
|
||||
assert body["daemon_id"] == DAEMON_ID
|
||||
assert body["broker"] == BROKER
|
||||
assert body["account_ref"] == ACCOUNT_REF
|
||||
assert body["has_credential"] is True
|
||||
assert body["credential_version"] == 1
|
||||
# Secret fields MUST NOT be in response (REQ-052 AC-1, REQ-054 AC-1)
|
||||
assert "app_key" not in body
|
||||
assert "app_secret" not in body
|
||||
assert "encrypted_payload" not in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-052 AC-2: 회전 시 credential_version 증가
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_put_credential_rotation_increments_version() -> None:
|
||||
"""REQ-052 AC-2: 동일 daemon 재PUT → credential_version 이전보다 1 증가."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
# Simulate rotation: upsert returns version=2
|
||||
cred_v2 = _make_cred(credential_version=2)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.upsert_broker_credential.return_value = cred_v2
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": ACCOUNT_REF, "broker": BROKER},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["credential_version"] == 2
|
||||
# upsert called with correct daemon_id
|
||||
mock_repo.upsert_broker_credential.assert_called_once()
|
||||
call_kwargs = mock_repo.upsert_broker_credential.call_args
|
||||
assert call_kwargs.kwargs["daemon_id"] == DAEMON_ID
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-052 AC-3: service-token /v1/user_info 정합 (CLI·API·service-token 경로 통일)
|
||||
# AC-3 is integration-level: verified by REQ-054 AC-6 (service-token GET /v1/user_info after PUT)
|
||||
# Here we verify upsert is called with the correct daemon_id + account_ref
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_put_credential_upsert_called_with_correct_daemon_id() -> None:
|
||||
"""REQ-052 AC-3 partial: PUT 이후 upsert에 daemon_id가 정확히 전달됨."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
cred = _make_cred(account_ref="my-account")
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.upsert_broker_credential.return_value = cred
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
client.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": "my-account", "broker": BROKER},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
mock_repo.upsert_broker_credential.assert_called_once()
|
||||
kwargs = mock_repo.upsert_broker_credential.call_args.kwargs
|
||||
assert kwargs["daemon_id"] == DAEMON_ID
|
||||
assert kwargs["account_ref"] == "my-account"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-052 AC-4: GET → 200 + 메타데이터 (secret 미포함)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_credential_meta_returns_metadata_without_secret() -> None:
|
||||
"""REQ-052 AC-4: user-token(소유자) GET → 200 + 메타데이터, secret 없음."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
cred = _make_cred()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.get_broker_credential_by_daemon.return_value = cred
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header())
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["broker"] == BROKER
|
||||
assert body["account_ref"] == ACCOUNT_REF
|
||||
assert body["has_credential"] is True
|
||||
assert body["credential_version"] == 1
|
||||
assert "app_key" not in body
|
||||
assert "app_secret" not in body
|
||||
assert "encrypted_payload" not in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-052 AC-5: DELETE → 204, 이후 GET has_credential=False
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_delete_credential_returns_204() -> None:
|
||||
"""REQ-052 AC-5: DELETE → 204."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.delete_broker_credential.return_value = True
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.delete(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header())
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
def test_get_after_delete_returns_has_credential_false() -> None:
|
||||
"""REQ-052 AC-5: DELETE 이후 GET → has_credential=False."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.get_broker_credential_by_daemon.return_value = None # deleted
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header())
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["has_credential"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-052 AC-6: 타 사용자 토큰 → 403 (소유 스코프 음성)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_put_credential_other_user_returns_403() -> None:
|
||||
"""REQ-052 AC-6: 다른 user의 토큰으로 PUT → 403."""
|
||||
settings = _make_settings()
|
||||
# OTHER user's principal
|
||||
principal = _make_user_principal(user_id=OTHER_USER_ID)
|
||||
daemon = _make_daemon(owner_user_id=OWNER_USER_ID) # owned by OWNER
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": "k", "app_secret": "s", "account_ref": "ref", "broker": BROKER},
|
||||
headers=_auth_header("other-token"),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_get_credential_other_user_returns_403() -> None:
|
||||
"""REQ-052 AC-6: 다른 user의 토큰으로 GET → 403."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal(user_id=OTHER_USER_ID)
|
||||
daemon = _make_daemon(owner_user_id=OWNER_USER_ID)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("other-token"))
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_delete_credential_other_user_returns_403() -> None:
|
||||
"""REQ-052 AC-6: 다른 user의 토큰으로 DELETE → 403."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal(user_id=OTHER_USER_ID)
|
||||
daemon = _make_daemon(owner_user_id=OWNER_USER_ID)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.delete(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("other-token"))
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-052 AC-7: 비인증 → 401
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_credential_no_token_returns_401() -> None:
|
||||
"""REQ-052 AC-7: 인증 토큰 없이 호출 → 401."""
|
||||
settings = _make_settings()
|
||||
client = _make_client(settings, MagicMock())
|
||||
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential")
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp2 = client.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": "k", "app_secret": "s", "account_ref": "ref"},
|
||||
)
|
||||
assert resp2.status_code == 401
|
||||
|
||||
resp3 = client.delete(f"/v1/daemons/{DAEMON_ID}/credential")
|
||||
assert resp3.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-052 AC-8: 미존재 daemon_id → 404
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_credential_nonexistent_daemon_returns_404() -> None:
|
||||
"""REQ-052 AC-8: 존재하지 않는 daemon_id → 404."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = None # not found
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get(f"/v1/daemons/nonexistent/credential", headers=_auth_header())
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_put_nonexistent_daemon_returns_404() -> None:
|
||||
"""REQ-052 AC-8: PUT 미존재 daemon → 404."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = None
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.put(
|
||||
"/v1/daemons/nonexistent/credential",
|
||||
json={"app_key": "k", "app_secret": "s", "account_ref": "ref"},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-052 AC-9: admin 토큰으로 타인 daemon 인증정보 write → 403
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_cannot_write_other_user_credential() -> None:
|
||||
"""REQ-052 AC-9: admin role이라도 타인 소유 daemon write → 403."""
|
||||
settings = _make_settings()
|
||||
admin_principal = _make_admin_principal(user_id="admin-user")
|
||||
daemon = _make_daemon(owner_user_id=OWNER_USER_ID) # owned by OWNER, not admin
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = admin_principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": "k", "app_secret": "s", "account_ref": "ref"},
|
||||
headers=_auth_header("admin-token"),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_admin_cannot_read_other_user_credential() -> None:
|
||||
"""REQ-052 AC-9: admin role이라도 타인 소유 daemon GET → 403."""
|
||||
settings = _make_settings()
|
||||
admin_principal = _make_admin_principal(user_id="admin-user")
|
||||
daemon = _make_daemon(owner_user_id=OWNER_USER_ID)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = admin_principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("admin-token"))
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-052 AC-10: service-token → 403
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_service_token_credential_get_returns_403() -> None:
|
||||
"""REQ-052 AC-10: service-token으로 GET /v1/daemons/{id}/credential → 403."""
|
||||
settings = _make_settings()
|
||||
svc_principal = _make_service_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = svc_principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("svc-token"))
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_service_token_credential_put_returns_403() -> None:
|
||||
"""REQ-052 AC-10: service-token으로 PUT → 403."""
|
||||
settings = _make_settings()
|
||||
svc_principal = _make_service_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = svc_principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": "k", "app_secret": "s", "account_ref": "ref"},
|
||||
headers=_auth_header("svc-token"),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_service_token_credential_delete_returns_403() -> None:
|
||||
"""REQ-052 AC-10: service-token으로 DELETE → 403."""
|
||||
settings = _make_settings()
|
||||
svc_principal = _make_service_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = svc_principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.delete(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header("svc-token"))
|
||||
|
||||
assert resp.status_code == 403
|
||||
@@ -0,0 +1,459 @@
|
||||
"""BAT-022: REQ-053 — POST/PATCH /v1/admin/daemons (admin 데몬 CRUD)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.domain.models import BrokerCredential, Daemon, TokenPrincipal, User
|
||||
from app.main import create_app
|
||||
|
||||
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
|
||||
ADMIN_USER_ID = "admin-user"
|
||||
OWNER_A_ID = "user-a"
|
||||
OWNER_B_ID = "user-b"
|
||||
DAEMON_ID = "daemon-001"
|
||||
|
||||
|
||||
def _make_settings(**kwargs) -> Settings:
|
||||
return Settings(
|
||||
allowed_origins_raw="http://localhost:3000",
|
||||
db_enabled=True,
|
||||
credential_encryption_key=_TEST_ENC_KEY,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _make_client(settings: Settings, mock_repo) -> TestClient:
|
||||
get_settings.cache_clear()
|
||||
app = create_app(settings_factory=lambda: settings)
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _auth_header(token: str = "admin-token") -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _make_daemon(
|
||||
daemon_id: str = DAEMON_ID,
|
||||
owner_user_id: str = OWNER_A_ID,
|
||||
daemon_slug: str = "trex-orb",
|
||||
display_name: str = "ORB",
|
||||
is_active: bool = True,
|
||||
) -> Daemon:
|
||||
return Daemon(
|
||||
id=daemon_id,
|
||||
owner_user_id=owner_user_id,
|
||||
daemon_slug=daemon_slug,
|
||||
display_name=display_name,
|
||||
is_active=is_active,
|
||||
created_at=datetime(2026, 6, 28, 10, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2026, 6, 28, 10, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def _make_admin_principal(user_id: str = ADMIN_USER_ID) -> TokenPrincipal:
|
||||
return TokenPrincipal(
|
||||
token_id="adm-1",
|
||||
user_id=user_id,
|
||||
permission="admin",
|
||||
token_type="admin",
|
||||
)
|
||||
|
||||
|
||||
def _make_user_principal(user_id: str = OWNER_A_ID) -> TokenPrincipal:
|
||||
return TokenPrincipal(
|
||||
token_id="usr-1",
|
||||
user_id=user_id,
|
||||
permission="read",
|
||||
token_type="user",
|
||||
)
|
||||
|
||||
|
||||
def _make_service_principal(user_id: str = OWNER_A_ID) -> TokenPrincipal:
|
||||
return TokenPrincipal(
|
||||
token_id="svc-1",
|
||||
user_id=user_id,
|
||||
permission="read",
|
||||
token_type="service",
|
||||
daemon_id=DAEMON_ID,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-1: POST 생성 → 201 + DaemonAdminResponse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_create_daemon_returns_201() -> None:
|
||||
"""REQ-053 AC-1: admin POST → 201 + id/daemon_slug/display_name/owner_user_id/is_active."""
|
||||
settings = _make_settings()
|
||||
principal = _make_admin_principal()
|
||||
daemon = _make_daemon()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.create_daemon.return_value = daemon
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.post(
|
||||
"/v1/admin/daemons",
|
||||
json={"daemon_slug": "trex-orb", "display_name": "ORB", "owner_user_id": OWNER_A_ID},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["id"] == DAEMON_ID
|
||||
assert body["daemon_slug"] == "trex-orb"
|
||||
assert body["display_name"] == "ORB"
|
||||
assert body["owner_user_id"] == OWNER_A_ID
|
||||
assert body["is_active"] is True
|
||||
# Secret fields must not appear (REQ-054 AC-3)
|
||||
assert "app_key" not in body
|
||||
assert "app_secret" not in body
|
||||
assert "credential_payload" not in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-2: 중복 slug → 409
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_create_daemon_duplicate_slug_returns_409() -> None:
|
||||
"""REQ-053 AC-2: 중복 daemon_slug → 409 Conflict."""
|
||||
settings = _make_settings()
|
||||
principal = _make_admin_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.create_daemon.side_effect = ValueError("이미 존재하는 daemon slug입니다: trex-orb")
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.post(
|
||||
"/v1/admin/daemons",
|
||||
json={"daemon_slug": "trex-orb", "owner_user_id": OWNER_A_ID},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-3: display_name 변경 — BrokerCredential 유지
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_patch_display_name_credential_preserved() -> None:
|
||||
"""REQ-053 AC-3: display_name 변경 → 200, BrokerCredential 삭제 없음."""
|
||||
settings = _make_settings()
|
||||
principal = _make_admin_principal()
|
||||
daemon_before = _make_daemon(display_name="OLD NAME")
|
||||
daemon_after = _make_daemon(display_name="NEW NAME")
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon_before
|
||||
mock_repo.update_daemon.return_value = daemon_after
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.patch(
|
||||
f"/v1/admin/daemons/{DAEMON_ID}",
|
||||
json={"display_name": "NEW NAME"},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["display_name"] == "NEW NAME"
|
||||
# update_daemon_owner must NOT be called (no owner change)
|
||||
mock_repo.update_daemon_owner.assert_not_called()
|
||||
# delete_credentials_by_daemon_id must NOT be called
|
||||
mock_repo.delete_credentials_by_daemon_id.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-4: owner 재할당 → BrokerCredential 자동 삭제
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_patch_owner_invalidates_credentials() -> None:
|
||||
"""REQ-053 AC-4: owner 재할당 → 200, BrokerCredential 자동 삭제(무효화)."""
|
||||
settings = _make_settings()
|
||||
principal = _make_admin_principal()
|
||||
daemon_before = _make_daemon(owner_user_id=OWNER_A_ID)
|
||||
daemon_after = _make_daemon(owner_user_id=OWNER_B_ID)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon_before
|
||||
mock_repo.update_daemon_owner.return_value = daemon_after
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.patch(
|
||||
f"/v1/admin/daemons/{DAEMON_ID}",
|
||||
json={"owner_user_id": OWNER_B_ID},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["owner_user_id"] == OWNER_B_ID
|
||||
# update_daemon_owner must be called (includes credential deletion)
|
||||
mock_repo.update_daemon_owner.assert_called_once_with(DAEMON_ID, owner_user_id=OWNER_B_ID)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-5: owner 변경 후 이전 owner의 /v1/user_info → broker=null
|
||||
# (verified via update_daemon_owner deleting credentials; AC-4 covers the mechanism)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_patch_owner_then_old_owner_loses_credential() -> None:
|
||||
"""REQ-053 AC-5: owner 재할당 이후 이전 owner 인증정보 제거 확인 (update_daemon_owner 호출로 검증)."""
|
||||
settings = _make_settings()
|
||||
principal = _make_admin_principal()
|
||||
daemon_before = _make_daemon(owner_user_id=OWNER_A_ID)
|
||||
daemon_after = _make_daemon(owner_user_id=OWNER_B_ID)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon_before
|
||||
mock_repo.update_daemon_owner.return_value = daemon_after
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.patch(
|
||||
f"/v1/admin/daemons/{DAEMON_ID}",
|
||||
json={"owner_user_id": OWNER_B_ID},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
# update_daemon_owner deletes credentials as part of the operation (see postgres.py implementation)
|
||||
mock_repo.update_daemon_owner.assert_called_once_with(DAEMON_ID, owner_user_id=OWNER_B_ID)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-6: 비활성화 → is_active=False + service token auto-revoke
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_deactivate_daemon_revokes_tokens() -> None:
|
||||
"""REQ-053 AC-6: 비활성화 → 200 + is_active=False, service token auto-revoke."""
|
||||
settings = _make_settings()
|
||||
principal = _make_admin_principal()
|
||||
daemon_active = _make_daemon(is_active=True)
|
||||
daemon_inactive = _make_daemon(is_active=False)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon_active
|
||||
mock_repo.update_daemon.return_value = daemon_inactive
|
||||
mock_repo.revoke_tokens_by_daemon_id.return_value = 1
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.post(f"/v1/admin/daemons/{DAEMON_ID}/deactivate", headers=_auth_header())
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_active"] is False
|
||||
mock_repo.update_daemon.assert_called_once_with(DAEMON_ID, is_active=False)
|
||||
mock_repo.revoke_tokens_by_daemon_id.assert_called_once_with(DAEMON_ID)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-7: 재활성화 → is_active=True
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_activate_daemon_returns_active() -> None:
|
||||
"""REQ-053 AC-7: 재활성화 → 200 + is_active=True."""
|
||||
settings = _make_settings()
|
||||
principal = _make_admin_principal()
|
||||
daemon_inactive = _make_daemon(is_active=False)
|
||||
daemon_active = _make_daemon(is_active=True)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon_inactive
|
||||
mock_repo.update_daemon.return_value = daemon_active
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.post(f"/v1/admin/daemons/{DAEMON_ID}/activate", headers=_auth_header())
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_active"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-8: DELETE → 405 (하드 삭제 미제공)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_delete_daemon_returns_405() -> None:
|
||||
"""REQ-053 AC-8: DELETE /v1/admin/daemons/{id} → 405 Method Not Allowed."""
|
||||
settings = _make_settings()
|
||||
principal = _make_admin_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.delete(f"/v1/admin/daemons/{DAEMON_ID}", headers=_auth_header())
|
||||
|
||||
assert resp.status_code == 405
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-9: user-token(비admin) → 403
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_non_admin_user_token_create_daemon_returns_403() -> None:
|
||||
"""REQ-053 AC-9: user-token(non-admin) → POST /v1/admin/daemons → 403."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal() # permission=read
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.post(
|
||||
"/v1/admin/daemons",
|
||||
json={"daemon_slug": "test", "owner_user_id": OWNER_A_ID},
|
||||
headers=_auth_header("user-token"),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_non_admin_patch_returns_403() -> None:
|
||||
"""REQ-053 AC-9: user-token → PATCH /v1/admin/daemons/{id} → 403."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.patch(
|
||||
f"/v1/admin/daemons/{DAEMON_ID}",
|
||||
json={"display_name": "X"},
|
||||
headers=_auth_header("user-token"),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_non_admin_deactivate_returns_403() -> None:
|
||||
"""REQ-053 AC-9: user-token → POST /v1/admin/daemons/{id}/deactivate → 403."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.post(
|
||||
f"/v1/admin/daemons/{DAEMON_ID}/deactivate",
|
||||
headers=_auth_header("user-token"),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-10: service-token → 403
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_service_token_admin_crud_returns_403() -> None:
|
||||
"""REQ-053 AC-10: service-token → POST /v1/admin/daemons → 403."""
|
||||
settings = _make_settings()
|
||||
svc_principal = _make_service_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = svc_principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.post(
|
||||
"/v1/admin/daemons",
|
||||
json={"daemon_slug": "test", "owner_user_id": OWNER_A_ID},
|
||||
headers=_auth_header("svc-token"),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-11: 비인증 → 401
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_crud_no_token_returns_401() -> None:
|
||||
"""REQ-053 AC-11: 비인증 → POST /v1/admin/daemons → 401."""
|
||||
settings = _make_settings()
|
||||
client = _make_client(settings, MagicMock())
|
||||
resp = client.post("/v1/admin/daemons", json={"daemon_slug": "test", "owner_user_id": "u1"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-053 AC-12: 미존재 daemon_id → 404
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_patch_nonexistent_daemon_returns_404() -> None:
|
||||
"""REQ-053 AC-12: PATCH 미존재 daemon → 404."""
|
||||
settings = _make_settings()
|
||||
principal = _make_admin_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = None
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.patch(
|
||||
"/v1/admin/daemons/nonexistent",
|
||||
json={"display_name": "X"},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_admin_deactivate_nonexistent_daemon_returns_404() -> None:
|
||||
"""REQ-053 AC-12: deactivate 미존재 daemon → 404."""
|
||||
settings = _make_settings()
|
||||
principal = _make_admin_principal()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = None
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.post(
|
||||
"/v1/admin/daemons/nonexistent/deactivate",
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,444 @@
|
||||
"""BAT-022: REQ-054 — 인증정보 관리 보안 격리 (write-only, secret 미반환, 로그 미노출) + REQ-051 회귀."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.encryption import decrypt_payload, encrypt_payload
|
||||
from app.domain.models import BrokerCredential, Daemon, TokenPrincipal, User
|
||||
from app.main import create_app
|
||||
|
||||
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
DAEMON_ID = "daemon-001"
|
||||
OWNER_USER_ID = "user-owner"
|
||||
BROKER = "kis"
|
||||
ACCOUNT_REF = "acc-001"
|
||||
APP_KEY = "real-app-key-secret"
|
||||
APP_SECRET = "real-app-secret-value"
|
||||
|
||||
|
||||
def _make_settings(**kwargs) -> Settings:
|
||||
return Settings(
|
||||
allowed_origins_raw="http://localhost:3000",
|
||||
db_enabled=True,
|
||||
credential_encryption_key=_TEST_ENC_KEY,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _make_client(settings: Settings, mock_repo) -> TestClient:
|
||||
get_settings.cache_clear()
|
||||
app = create_app(settings_factory=lambda: settings)
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _auth_header(token: str = "user-token") -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _make_daemon(owner_user_id: str = OWNER_USER_ID) -> Daemon:
|
||||
return Daemon(
|
||||
id=DAEMON_ID,
|
||||
owner_user_id=owner_user_id,
|
||||
daemon_slug="trex-orb",
|
||||
display_name="ORB",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
|
||||
def _make_cred(
|
||||
account_ref: str = ACCOUNT_REF,
|
||||
credential_version: int = 1,
|
||||
app_key: str = APP_KEY,
|
||||
app_secret: str = APP_SECRET,
|
||||
) -> BrokerCredential:
|
||||
payload_json = json.dumps({"app_key": app_key, "app_secret": app_secret})
|
||||
encrypted = encrypt_payload(_TEST_ENC_KEY, payload_json)
|
||||
return BrokerCredential(
|
||||
user_id=OWNER_USER_ID,
|
||||
broker=BROKER,
|
||||
account_ref=account_ref,
|
||||
encrypted_payload=encrypted,
|
||||
credential_version=credential_version,
|
||||
last_rotated_at=datetime(2026, 6, 28, 12, 0, 0, tzinfo=timezone.utc),
|
||||
daemon_id=DAEMON_ID,
|
||||
)
|
||||
|
||||
|
||||
def _make_user_principal(user_id: str = OWNER_USER_ID) -> TokenPrincipal:
|
||||
return TokenPrincipal(
|
||||
token_id="tok-1",
|
||||
user_id=user_id,
|
||||
permission="read",
|
||||
token_type="user",
|
||||
)
|
||||
|
||||
|
||||
def _make_admin_principal(user_id: str = "admin-user") -> TokenPrincipal:
|
||||
return TokenPrincipal(
|
||||
token_id="adm-1",
|
||||
user_id=user_id,
|
||||
permission="admin",
|
||||
token_type="admin",
|
||||
)
|
||||
|
||||
|
||||
def _make_service_principal(user_id: str = OWNER_USER_ID, daemon_id: str = DAEMON_ID) -> TokenPrincipal:
|
||||
return TokenPrincipal(
|
||||
token_id="svc-1",
|
||||
user_id=user_id,
|
||||
permission="read",
|
||||
token_type="service",
|
||||
runtime_id="rt-1",
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-054 AC-1: PUT 응답에 app_key/app_secret 없음
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_put_credential_response_has_no_secret_fields() -> None:
|
||||
"""REQ-054 AC-1: PUT 응답 JSON에 app_key, app_secret 키 없음(최상위·중첩 포함)."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
cred = _make_cred()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.upsert_broker_credential.return_value = cred
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": ACCOUNT_REF, "broker": BROKER},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
# Recursively check no secret fields anywhere in response
|
||||
body_str = json.dumps(body)
|
||||
assert "app_key" not in body_str
|
||||
assert "app_secret" not in body_str
|
||||
assert "encrypted_payload" not in body_str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-054 AC-2: GET 응답에 app_key/app_secret 없음
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_credential_meta_response_has_no_secret_fields() -> None:
|
||||
"""REQ-054 AC-2: GET 응답 JSON에 app_key, app_secret 키 없음."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
cred = _make_cred()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.get_broker_credential_by_daemon.return_value = cred
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get(f"/v1/daemons/{DAEMON_ID}/credential", headers=_auth_header())
|
||||
|
||||
assert resp.status_code == 200
|
||||
body_str = json.dumps(resp.json())
|
||||
assert "app_key" not in body_str
|
||||
assert "app_secret" not in body_str
|
||||
assert "encrypted_payload" not in body_str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-054 AC-3: admin CRUD 응답에 app_key/app_secret/credential_payload 없음
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_admin_create_response_has_no_credential_fields() -> None:
|
||||
"""REQ-054 AC-3: POST /v1/admin/daemons 응답에 app_key/app_secret/credential_payload 없음."""
|
||||
settings = _make_settings()
|
||||
from app.domain.models import Daemon
|
||||
principal = _make_admin_principal()
|
||||
daemon = Daemon(
|
||||
id=DAEMON_ID, owner_user_id=OWNER_USER_ID, daemon_slug="trex-orb",
|
||||
display_name="ORB", is_active=True,
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.create_daemon.return_value = daemon
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.post(
|
||||
"/v1/admin/daemons",
|
||||
json={"daemon_slug": "trex-orb", "owner_user_id": OWNER_USER_ID},
|
||||
headers=_auth_header("admin-token"),
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
body_str = json.dumps(resp.json())
|
||||
assert "app_key" not in body_str
|
||||
assert "app_secret" not in body_str
|
||||
assert "credential_payload" not in body_str
|
||||
|
||||
|
||||
def test_admin_patch_response_has_no_credential_fields() -> None:
|
||||
"""REQ-054 AC-3: PATCH /v1/admin/daemons/{id} 응답에 app_key/app_secret/credential_payload 없음."""
|
||||
settings = _make_settings()
|
||||
from app.domain.models import Daemon
|
||||
principal = _make_admin_principal()
|
||||
daemon = Daemon(
|
||||
id=DAEMON_ID, owner_user_id=OWNER_USER_ID, daemon_slug="trex-orb",
|
||||
display_name="NEW", is_active=True,
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.update_daemon.return_value = daemon
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_admin_daemons.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.patch(
|
||||
f"/v1/admin/daemons/{DAEMON_ID}",
|
||||
json={"display_name": "NEW"},
|
||||
headers=_auth_header("admin-token"),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body_str = json.dumps(resp.json())
|
||||
assert "app_key" not in body_str
|
||||
assert "app_secret" not in body_str
|
||||
assert "credential_payload" not in body_str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-054 AC-4: caplog — PUT 처리 중 로그에 app_key/app_secret 값 미포함
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_put_credential_log_does_not_expose_secret(caplog) -> None:
|
||||
"""REQ-054 AC-4: PUT 처리 중 로그에 실제 app_key/app_secret 값 미포함."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
cred = _make_cred(app_key=APP_KEY, app_secret=APP_SECRET)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.upsert_broker_credential.return_value = cred
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="app.api.v1_daemon_credential"), \
|
||||
patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
client.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": ACCOUNT_REF, "broker": BROKER},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
log_text = " ".join(caplog.messages)
|
||||
# Actual values must not appear in logs
|
||||
assert APP_KEY not in log_text, f"app_key found in logs: {log_text}"
|
||||
assert APP_SECRET not in log_text, f"app_secret found in logs: {log_text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-054 AC-5: DB に암호화 저장 확인 (encrypted_payload가 gAAAA... 형태인지 upsert 호출 인수로 검증)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_put_credential_stores_encrypted_payload() -> None:
|
||||
"""REQ-054 AC-5: PUT 시 Fernet 암호화된 payload를 DB에 저장함 확인."""
|
||||
settings = _make_settings()
|
||||
principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
cred = _make_cred()
|
||||
|
||||
captured_encrypted = {}
|
||||
|
||||
def mock_upsert(user_id, broker, *, account_ref, encrypted_payload, daemon_id=None):
|
||||
captured_encrypted["payload"] = encrypted_payload
|
||||
return cred
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_daemon_by_id.return_value = daemon
|
||||
mock_repo.upsert_broker_credential.side_effect = mock_upsert
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": ACCOUNT_REF, "broker": BROKER},
|
||||
headers=_auth_header(),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
# The stored payload must be a Fernet token (starts with gAAAA)
|
||||
stored = captured_encrypted["payload"]
|
||||
assert stored.startswith("gAAAA"), f"Not a Fernet token: {stored[:20]}"
|
||||
# Must not be plaintext
|
||||
assert APP_KEY not in stored
|
||||
assert APP_SECRET not in stored
|
||||
# Verify it actually decrypts correctly
|
||||
decrypted = json.loads(decrypt_payload(_TEST_ENC_KEY, stored))
|
||||
assert decrypted["app_key"] == APP_KEY
|
||||
assert decrypted["app_secret"] == APP_SECRET
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-054 AC-6: PUT 이후 service-token GET /v1/user_info → correct account_ref returned
|
||||
# (유일 decrypt 경로 불변 확인)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_put_then_service_token_user_info_returns_correct_account_ref() -> None:
|
||||
"""REQ-054 AC-6: PUT 후 service-token /v1/user_info에서 동일 account_ref 반환 (decrypt 경로 불변)."""
|
||||
settings = _make_settings()
|
||||
|
||||
# 1. PUT credential
|
||||
user_principal = _make_user_principal()
|
||||
daemon = _make_daemon()
|
||||
cred_stored = _make_cred(account_ref="my-account-789", app_key=APP_KEY, app_secret=APP_SECRET)
|
||||
|
||||
mock_repo_put = MagicMock()
|
||||
mock_repo_put.get_token_principal.return_value = user_principal
|
||||
mock_repo_put.get_daemon_by_id.return_value = daemon
|
||||
mock_repo_put.upsert_broker_credential.return_value = cred_stored
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo_put), \
|
||||
patch("app.api.v1_daemon_credential.get_portfolio_repository_from_settings", return_value=mock_repo_put):
|
||||
client_put = _make_client(settings, mock_repo_put)
|
||||
resp_put = client_put.put(
|
||||
f"/v1/daemons/{DAEMON_ID}/credential",
|
||||
json={"app_key": APP_KEY, "app_secret": APP_SECRET, "account_ref": "my-account-789", "broker": BROKER},
|
||||
headers=_auth_header("user-token"),
|
||||
)
|
||||
assert resp_put.status_code == 200
|
||||
|
||||
# 2. GET /v1/user_info via service-token
|
||||
svc_principal = _make_service_principal()
|
||||
user = User(id=OWNER_USER_ID, username="alice", permission="read", is_active=True)
|
||||
|
||||
mock_repo_svc = MagicMock()
|
||||
mock_repo_svc.get_token_principal.return_value = svc_principal
|
||||
mock_repo_svc.get_user.return_value = user
|
||||
mock_repo_svc.list_broker_credentials.return_value = [cred_stored]
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo_svc), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo_svc):
|
||||
client_svc = _make_client(settings, mock_repo_svc)
|
||||
resp_svc = client_svc.get("/v1/user_info", headers={"Authorization": "Bearer svc-token"})
|
||||
|
||||
assert resp_svc.status_code == 200
|
||||
body = resp_svc.json()
|
||||
assert body["broker"] is not None
|
||||
assert body["broker"]["account_ref"] == "my-account-789"
|
||||
# credential_payload must be decrypted and returned on service-token path
|
||||
assert body["broker"]["credential_payload"] is not None
|
||||
assert body["broker"]["credential_payload"]["app_key"] == APP_KEY
|
||||
assert body["broker"]["credential_payload"]["app_secret"] == APP_SECRET
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-054 AC-7: REQ-051 회귀 — 신규 API 추가 후 기존 service-token 보안 격리 PASS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_req051_regression_web_token_user_info_still_403() -> None:
|
||||
"""REQ-054 AC-7 / REQ-051 AC-1 회귀: web token → /v1/user_info → 403 (불변)."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id=OWNER_USER_ID, permission="read",
|
||||
token_type="user", daemon_id=DAEMON_ID,
|
||||
)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers={"Authorization": "Bearer web-token"})
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_req051_regression_service_token_without_daemon_id_403() -> None:
|
||||
"""REQ-054 AC-7 / REQ-051 AC-3 회귀: service-token without daemon_id → 403."""
|
||||
settings = _make_settings()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id=OWNER_USER_ID, permission="read",
|
||||
token_type="service", daemon_id=None,
|
||||
)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers={"Authorization": "Bearer svc-no-daemon"})
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_req051_regression_unauthenticated_401() -> None:
|
||||
"""REQ-054 AC-7 / REQ-051 AC-2 회귀: 비인증 → 401."""
|
||||
settings = _make_settings()
|
||||
client = _make_client(settings, MagicMock())
|
||||
resp = client.get("/v1/user_info")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_req051_regression_service_token_with_daemon_returns_broker() -> None:
|
||||
"""REQ-054 AC-7 / REQ-051 AC-1 회귀: service-token + daemon_id → 200 + broker (불변)."""
|
||||
settings = _make_settings()
|
||||
svc_principal = _make_service_principal()
|
||||
user = User(id=OWNER_USER_ID, username="alice", permission="read", is_active=True)
|
||||
cred = _make_cred()
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = svc_principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = [cred]
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_client(settings, mock_repo)
|
||||
resp = client.get("/v1/user_info", headers={"Authorization": "Bearer svc-token"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["broker"] is not None
|
||||
assert body["broker"]["account_ref"] == ACCOUNT_REF
|
||||
assert body["broker"]["credential_payload"]["app_key"] == APP_KEY
|
||||
assert body["broker"]["credential_payload"]["app_secret"] == APP_SECRET
|
||||
|
||||
|
||||
def test_req051_regression_other_endpoints_no_credential_exposure() -> None:
|
||||
"""REQ-054 AC-7 / REQ-051 AC-5 회귀: /health 등에 credential 필드 없음."""
|
||||
settings = _make_settings()
|
||||
client = TestClient(create_app(settings_factory=lambda: settings))
|
||||
resp = client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "app_key" not in body
|
||||
assert "app_secret" not in body
|
||||
assert "credential_payload" not in body
|
||||
assert "encrypted_payload" not in body
|
||||
Reference in New Issue
Block a user