Fix medium/low priority review items; update README

Medium:
- Frontend: Error-Handling in fetchUsers/fetchApiKeys (try/catch)
- Frontend: Loading-Race behoben (Promise.all + .finally)
- Frontend: API-Keys maskiert (nur letzte 4 Zeichen sichtbar)
- Tests: Setup-Code aus test_auth.py in conftest.py konsolidiert
- Tests: Fixture-Scope vereinheitlicht (function statt session)

Low:
- bare except in database.py → except Exception
- datetime.utcnow → datetime.now(timezone.utc) durchgängig
- DateTime(timezone=True) in allen Modell-Spalten
- .gitignore hinzugefügt (.env, *.db, __pycache__, .idea, node_modules)

Docs:
- README aktualisiert (Sicherheit, Konfiguration, Projektstruktur, Tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Oliver Hofmann
2026-04-27 21:48:26 +02:00
co-authored by Claude Sonnet 4.6
parent bf694b79e2
commit cfa874a4c3
9 changed files with 188 additions and 214 deletions
+2 -2
View File
@@ -2,7 +2,7 @@ import secrets
import hashlib
import bcrypt
import tiktoken
from datetime import datetime
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from models import APIKey, User, Quota, Usage
@@ -89,7 +89,7 @@ def check_and_increment_quota(db: Session, user_id: int, tokens: int = 0, reques
db.add(usage)
db.flush()
now = datetime.utcnow()
now = datetime.now(timezone.utc)
if usage.daily_reset_at.date() < now.date():
usage.tokens_used_today = 0
+1 -1
View File
@@ -8,7 +8,7 @@ try:
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
else:
engine = create_engine(DATABASE_URL)
except:
except Exception:
DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
+8 -6
View File
@@ -1,7 +1,9 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, BigInteger
from datetime import datetime
from datetime import datetime, timezone
from database import Base
_now = lambda: datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
@@ -11,7 +13,7 @@ class User(Base):
hashed_password = Column(String)
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime(timezone=True), default=_now)
class APIKey(Base):
__tablename__ = "api_keys"
@@ -21,7 +23,7 @@ class APIKey(Base):
key = Column(String, unique=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime(timezone=True), default=_now)
class Quota(Base):
__tablename__ = "quotas"
@@ -32,7 +34,7 @@ class Quota(Base):
monthly_tokens = Column(BigInteger, nullable=True)
daily_requests = Column(Integer, nullable=True)
monthly_requests = Column(Integer, nullable=True)
reset_at = Column(DateTime, default=datetime.utcnow)
reset_at = Column(DateTime(timezone=True), default=_now)
class Usage(Base):
__tablename__ = "usage"
@@ -43,5 +45,5 @@ class Usage(Base):
tokens_used_month = Column(BigInteger, default=0)
requests_today = Column(Integer, default=0)
requests_month = Column(Integer, default=0)
daily_reset_at = Column(DateTime, default=datetime.utcnow)
monthly_reset_at = Column(DateTime, default=datetime.utcnow)
daily_reset_at = Column(DateTime(timezone=True), default=_now)
monthly_reset_at = Column(DateTime(timezone=True), default=_now)
+35 -42
View File
@@ -1,49 +1,41 @@
import pytest
from fastapi.testclient import TestClient
import tempfile
import os
from pathlib import Path
def create_test_db():
"""Create a temporary SQLite database for tests."""
temp_db = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
temp_db.close()
os.environ["DATABASE_URL"] = f"sqlite:///{temp_db.name}"
return temp_db.name
os.environ.setdefault("OLLAMA_URL", "http://127.0.0.1:9999")
def cleanup_test_db(db_path):
"""Remove the temporary database."""
if os.path.exists(db_path):
os.unlink(db_path)
os.environ.pop("DATABASE_URL", None)
def setup_test_db():
"""Setup test database with required data."""
def _setup_db():
from database import Base, engine, SessionLocal
from models import User, APIKey, Quota, Usage
from models import User, Quota
from crud import create_api_key, hash_password
# Create tables
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
db = SessionLocal()
# Create test user
test_user = User(
username="testuser",
email="test@example.com",
hashed_password=hash_password("test123"),
is_active=True
is_active=True,
)
db.add(test_user)
db.commit()
db.refresh(test_user)
# Create API key for test user
db.add(Quota(
user_id=test_user.id,
daily_tokens=1_000_000,
monthly_tokens=10_000_000,
daily_requests=1000,
monthly_requests=10000,
))
db.commit()
_, raw_key = create_api_key(db, test_user.id, "test-key")
os.environ["TEST_API_KEY"] = raw_key
# Create admin user
admin_user = User(
username="admin",
email="admin@example.com",
@@ -55,33 +47,34 @@ def setup_test_db():
db.commit()
db.refresh(admin_user)
# Create admin API key
db.add(Quota(
user_id=admin_user.id,
daily_tokens=10_000_000,
monthly_tokens=100_000_000,
daily_requests=10000,
monthly_requests=100000,
))
db.commit()
_, admin_raw_key = create_api_key(db, admin_user.id, "admin-key")
os.environ["ADMIN_API_KEY"] = admin_raw_key
db.close()
return raw_key, admin_raw_key
def teardown_test_db():
"""Clean up test database and environment."""
def _teardown_db():
from database import engine
from models import Base
Base.metadata.drop_all(bind=engine)
os.environ.pop("TEST_API_KEY", None)
os.environ.pop("ADMIN_API_KEY", None)
@pytest.fixture(scope="session")
@pytest.fixture(scope="function")
def test_client():
"""Create test client with test database."""
db_path = create_test_db()
setup_test_db()
_setup_db()
from main import app
client = TestClient(app)
from fastapi.testclient import TestClient
client = TestClient(app, raise_server_exceptions=False)
yield client
teardown_test_db()
cleanup_test_db(db_path)
_teardown_db()
+5 -81
View File
@@ -1,106 +1,30 @@
import pytest
import os
from unittest.mock import AsyncMock, patch
from fastapi.testclient import TestClient
from main import app
from database import Base, engine, SessionLocal
from models import User, APIKey, Quota
from crud import create_api_key, hash_password
os.environ["OLLAMA_URL"] = "http://127.0.0.1:9999"
def setup_test_db():
"""Setup test database with required data."""
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
db = SessionLocal()
test_user = User(
username="testuser",
email="test@example.com",
hashed_password=hash_password("test123"),
is_active=True
)
db.add(test_user)
db.commit()
db.refresh(test_user)
quota = Quota(
user_id=test_user.id,
daily_tokens=1000000,
monthly_tokens=10000000,
daily_requests=1000,
monthly_requests=10000
)
db.add(quota)
db.commit()
api_key_record, raw_key = create_api_key(db, test_user.id, "test-key")
os.environ["TEST_API_KEY"] = raw_key
admin_user = User(
username="admin",
email="admin@example.com",
hashed_password=hash_password("admin123"),
is_active=True,
is_admin=True,
)
db.add(admin_user)
db.commit()
db.refresh(admin_user)
admin_quota = Quota(
user_id=admin_user.id,
daily_tokens=10000000,
monthly_tokens=100000000,
daily_requests=10000,
monthly_requests=100000
)
db.add(admin_quota)
db.commit()
_, admin_raw_key = create_api_key(db, admin_user.id, "admin-key")
os.environ["ADMIN_API_KEY"] = admin_raw_key
db.close()
return os.environ["TEST_API_KEY"], os.environ["ADMIN_API_KEY"]
def teardown_test_db():
"""Clean up test database and environment."""
Base.metadata.drop_all(bind=engine)
os.environ.pop("TEST_API_KEY", None)
os.environ.pop("ADMIN_API_KEY", None)
@pytest.fixture(scope="function")
def test_client():
setup_test_db()
client = TestClient(app, raise_server_exceptions=False)
yield client
teardown_test_db()
def test_auth_middleware_missing_auth(test_client):
response = test_client.post("/api/generate", json={"model": "llama3", "prompt": "test"})
assert response.status_code == 401
def test_auth_middleware_invalid_key(test_client):
response = test_client.post(
"/api/generate",
headers={"Authorization": "sk-invalid-key"},
json={"model": "llama3", "prompt": "test"}
json={"model": "llama3", "prompt": "test"},
)
assert response.status_code == 401
@patch("main.proxy_request", new_callable=AsyncMock)
def test_auth_middleware_valid_key(mock_proxy, test_client):
mock_proxy.return_value.status_code = 200
mock_proxy.return_value.json = lambda: {"response": "success"}
mock_proxy.return_value.headers = {}
response = test_client.post(
"/api/generate",
headers={"Authorization": os.environ.get("TEST_API_KEY", "")},
json={"model": "llama3", "prompt": "test"}
json={"model": "llama3", "prompt": "test"},
)
assert response.status_code == 200
+5 -5
View File
@@ -1,6 +1,6 @@
import pytest
import os
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
os.environ.setdefault("OLLAMA_URL", "http://127.0.0.1:9999")
@@ -126,7 +126,7 @@ def test_daily_reset_restores_access(db):
# Backdate daily_reset_at to yesterday
usage = db.query(Usage).filter(Usage.user_id == user_id).first()
usage.daily_reset_at = datetime.utcnow() - timedelta(days=1)
usage.daily_reset_at = datetime.now(timezone.utc) - timedelta(days=1)
db.commit()
# Should pass again after reset
@@ -140,7 +140,7 @@ def test_daily_reset_does_not_affect_monthly_counter(db):
check_and_increment_quota(db, user_id, tokens=50, requests=1)
usage = db.query(Usage).filter(Usage.user_id == user_id).first()
usage.daily_reset_at = datetime.utcnow() - timedelta(days=1)
usage.daily_reset_at = datetime.now(timezone.utc) - timedelta(days=1)
db.commit()
check_and_increment_quota(db, user_id, tokens=50, requests=1)
@@ -154,7 +154,7 @@ def test_monthly_reset_restores_access(db):
check_and_increment_quota(db, user_id, tokens=90, requests=1)
usage = db.query(Usage).filter(Usage.user_id == user_id).first()
usage.monthly_reset_at = datetime.utcnow() - timedelta(days=32)
usage.monthly_reset_at = datetime.now(timezone.utc) - timedelta(days=32)
db.commit()
assert check_and_increment_quota(db, user_id, tokens=90, requests=1) is True
@@ -168,7 +168,7 @@ def test_failed_quota_check_still_commits_reset(db):
# Backdate so a reset fires, but the new request still exceeds the limit
usage = db.query(Usage).filter(Usage.user_id == user_id).first()
usage.daily_reset_at = datetime.utcnow() - timedelta(days=1)
usage.daily_reset_at = datetime.now(timezone.utc) - timedelta(days=1)
usage.tokens_used_today = 80
db.commit()