Add admin config and audit log
Bearer-token-gated admin endpoints to read/update the DB-backed operational config (fee_address, bet_amount_sats) without a redeploy, plus a lightweight audit log writer for round/payout/config events. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.db.session import get_session
|
||||
from app.rounds.config import get_round_config
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
async def require_admin(x_admin_token: str = Header(default="")) -> None:
|
||||
if not settings.admin_token or x_admin_token != settings.admin_token:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token")
|
||||
|
||||
|
||||
class RoundConfigResponse(BaseModel):
|
||||
fee_address: str
|
||||
bet_amount_sats: int
|
||||
|
||||
|
||||
class RoundConfigUpdate(BaseModel):
|
||||
fee_address: str | None = None
|
||||
bet_amount_sats: int | None = None
|
||||
|
||||
|
||||
@router.get("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
|
||||
async def read_config(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse:
|
||||
config = await get_round_config(session)
|
||||
await session.commit()
|
||||
return RoundConfigResponse(fee_address=config.fee_address, bet_amount_sats=config.bet_amount_sats)
|
||||
|
||||
|
||||
@router.put("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
|
||||
async def update_config(
|
||||
body: RoundConfigUpdate, session: AsyncSession = Depends(get_session)
|
||||
) -> RoundConfigResponse:
|
||||
config = await get_round_config(session)
|
||||
if body.fee_address is not None:
|
||||
config.fee_address = body.fee_address
|
||||
if body.bet_amount_sats is not None:
|
||||
config.bet_amount_sats = body.bet_amount_sats
|
||||
await session.commit()
|
||||
return RoundConfigResponse(fee_address=config.fee_address, bet_amount_sats=config.bet_amount_sats)
|
||||
@@ -0,0 +1,22 @@
|
||||
import json
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import AuditLog
|
||||
|
||||
|
||||
async def write_audit_log(
|
||||
session: AsyncSession,
|
||||
event_type: str,
|
||||
payload: dict,
|
||||
user_id: int | None = None,
|
||||
round_id: int | None = None,
|
||||
) -> None:
|
||||
session.add(
|
||||
AuditLog(
|
||||
event_type=event_type,
|
||||
payload_json=json.dumps(payload),
|
||||
user_id=user_id,
|
||||
round_id=round_id,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
|
||||
monkeypatch.setattr(settings, "admin_token", "test-admin-token")
|
||||
monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret")
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.db import base as db_base
|
||||
|
||||
db_base.engine = create_async_engine(settings.database_url)
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
|
||||
|
||||
async with db_base.engine.begin() as conn:
|
||||
await conn.run_sync(db_base.Base.metadata.create_all)
|
||||
|
||||
from app.api.routes.admin import router as admin_router
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(admin_router)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
await db_base.engine.dispose()
|
||||
|
||||
|
||||
async def test_admin_requires_token(client):
|
||||
resp = await client.get("/admin/config")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_admin_rejects_wrong_token(client):
|
||||
resp = await client.get("/admin/config", headers={"X-Admin-Token": "wrong"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_admin_reads_and_updates_config(client):
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
|
||||
resp = await client.get("/admin/config", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["fee_address"] == ""
|
||||
|
||||
resp = await client.put(
|
||||
"/admin/config", headers=headers, json={"fee_address": "plm1qfeeaddress", "bet_amount_sats": 500_000_000}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["fee_address"] == "plm1qfeeaddress"
|
||||
assert body["bet_amount_sats"] == 500_000_000
|
||||
|
||||
resp = await client.get("/admin/config", headers=headers)
|
||||
assert resp.json()["fee_address"] == "plm1qfeeaddress"
|
||||
Reference in New Issue
Block a user