feat: JWT auth, user management, audit logging system
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
"""Audit API: list audit logs — only owner."""
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.middleware.auth import require_role
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_audit(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
action: str | None = Query(None),
|
||||||
|
user: User = Depends(require_role("owner")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
query = select(AuditLog).order_by(AuditLog.created_at.desc())
|
||||||
|
if action:
|
||||||
|
query = query.where(AuditLog.action.ilike(f"%{action}%"))
|
||||||
|
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||||
|
result = await db.execute(query)
|
||||||
|
return result.scalars().all()
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Auth API: register, login, refresh, change-password."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.middleware.auth import require_auth
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.auth import (
|
||||||
|
ChangePasswordRequest,
|
||||||
|
LoginRequest,
|
||||||
|
RegisterRequest,
|
||||||
|
TokenResponse,
|
||||||
|
)
|
||||||
|
from app.schemas.user import UserResponse
|
||||||
|
from app.services.auth_service import (
|
||||||
|
authenticate_user,
|
||||||
|
create_access_token,
|
||||||
|
create_user,
|
||||||
|
decode_access_token,
|
||||||
|
get_user_by_username,
|
||||||
|
hash_password,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", status_code=201, response_model=UserResponse)
|
||||||
|
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
existing = await get_user_by_username(db, body.username)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=409, detail="Username already exists")
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.email == body.email))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=409, detail="Email already exists")
|
||||||
|
|
||||||
|
user = await create_user(db, body.username, body.email, body.password)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse)
|
||||||
|
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
user = await authenticate_user(db, body.username, body.password)
|
||||||
|
token = create_access_token(user.id, user.role.value)
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=token,
|
||||||
|
expires_in=settings.jwt_expire_minutes * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
|
async def refresh(user: User = Depends(require_auth)):
|
||||||
|
token = create_access_token(user.id, user.role.value)
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=token,
|
||||||
|
expires_in=settings.jwt_expire_minutes * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password")
|
||||||
|
async def change_password(
|
||||||
|
body: ChangePasswordRequest,
|
||||||
|
user: User = Depends(require_auth),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if not verify_password(body.old_password, user.hashed_password):
|
||||||
|
raise HTTPException(status_code=400, detail="Old password is incorrect")
|
||||||
|
|
||||||
|
user.hashed_password = hash_password(body.new_password)
|
||||||
|
await db.commit()
|
||||||
|
return {"detail": "Password changed successfully"}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""User management API: list, detail, update, approve — only owner."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.middleware.auth import require_role
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.user import UserResponse, UserUpdateRequest
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[UserResponse])
|
||||||
|
async def list_users(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
user: User = Depends(require_role("owner")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).offset((page - 1) * page_size).limit(page_size)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=UserResponse)
|
||||||
|
async def get_user(
|
||||||
|
user_id: str,
|
||||||
|
user: User = Depends(require_role("owner")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
target = result.scalar_one_or_none()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}", response_model=UserResponse)
|
||||||
|
async def update_user(
|
||||||
|
user_id: str,
|
||||||
|
body: UserUpdateRequest,
|
||||||
|
user: User = Depends(require_role("owner")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
target = result.scalar_one_or_none()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
if body.role is not None:
|
||||||
|
target.role = body.role
|
||||||
|
if body.is_active is not None:
|
||||||
|
target.is_active = body.is_active
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/approve", response_model=UserResponse)
|
||||||
|
async def approve_user(
|
||||||
|
user_id: str,
|
||||||
|
user: User = Depends(require_role("owner")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
target = result.scalar_one_or_none()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if target.is_approved:
|
||||||
|
raise HTTPException(status_code=400, detail="User already approved")
|
||||||
|
|
||||||
|
target.is_approved = True
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(target)
|
||||||
|
return target
|
||||||
@@ -4,6 +4,8 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.database import engine, Base
|
from app.database import engine, Base
|
||||||
|
from app.api import auth, users, audit
|
||||||
|
from app.middleware.audit import AuditMiddleware
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -27,6 +29,11 @@ app.add_middleware(
|
|||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
app.add_middleware(AuditMiddleware)
|
||||||
|
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(users.router)
|
||||||
|
app.include_router(audit.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/health")
|
@app.get("/api/v1/health")
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Audit middleware: log all mutating requests to AuditLog."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from app.database import async_session
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
from app.services.auth_service import decode_access_token, get_user_by_id
|
||||||
|
|
||||||
|
|
||||||
|
class AuditMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
|
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
|
||||||
|
response = await call_next(request)
|
||||||
|
await self._log(request, response)
|
||||||
|
return response
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
async def _log(self, request: Request, response: Response):
|
||||||
|
try:
|
||||||
|
user_id = "anonymous"
|
||||||
|
username = "anonymous"
|
||||||
|
|
||||||
|
auth_header = request.headers.get("authorization")
|
||||||
|
if auth_header and auth_header.startswith("Bearer "):
|
||||||
|
try:
|
||||||
|
payload = decode_access_token(auth_header[7:])
|
||||||
|
async with async_session() as db:
|
||||||
|
user = await get_user_by_id(db, payload["sub"])
|
||||||
|
if user:
|
||||||
|
user_id = user.id
|
||||||
|
username = user.username
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
path = request.url.path
|
||||||
|
action = f"{request.method} {path}"
|
||||||
|
|
||||||
|
detail = {}
|
||||||
|
if response.status_code >= 400:
|
||||||
|
body = b""
|
||||||
|
try:
|
||||||
|
body = await request.body()
|
||||||
|
if body:
|
||||||
|
detail["request_body"] = json.loads(body)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async with async_session() as db:
|
||||||
|
log = AuditLog(
|
||||||
|
user_id=user_id,
|
||||||
|
username=username,
|
||||||
|
action=action,
|
||||||
|
target=path,
|
||||||
|
detail=json.dumps(detail) if detail else None,
|
||||||
|
ip_address=request.client.host if request.client else None,
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.services.auth_service import decode_access_token, get_user_by_id
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
async def require_auth(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
payload = decode_access_token(credentials.credentials)
|
||||||
|
user = await get_user_by_id(db, payload["sub"])
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or inactive user")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_role(*roles: str):
|
||||||
|
async def role_check(user=Depends(require_auth)):
|
||||||
|
if user.role.value not in roles:
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
return user
|
||||||
|
return role_check
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
email: str # 先用 str,后续升级 EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
old_password: str
|
||||||
|
new_password: str
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
role: str
|
||||||
|
is_active: bool
|
||||||
|
is_approved: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdateRequest(BaseModel):
|
||||||
|
role: str | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Auth service: password hashing, JWT, user queries."""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from jose import JWTError, ExpiredSignatureError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
|
||||||
|
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return _pwd.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return _pwd.verify(plain, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(user_id: str, role: str) -> str:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
|
||||||
|
payload = {"sub": user_id, "role": role, "exp": expire}
|
||||||
|
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_access_token(token: str) -> dict:
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||||
|
except ExpiredSignatureError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_by_id(db: AsyncSession, user_id: str) -> User | None:
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
|
||||||
|
result = await db.execute(select(User).where(User.username == username))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate_user(db: AsyncSession, username: str, password: str) -> User:
|
||||||
|
user = await get_user_by_username(db, username)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||||
|
|
||||||
|
# Check lock
|
||||||
|
if user.locked_until and user.locked_until > datetime.utcnow():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_423_LOCKED,
|
||||||
|
detail=f"Account locked until {user.locked_until.isoformat()}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not verify_password(password, user.hashed_password):
|
||||||
|
user.login_attempts += 1
|
||||||
|
if user.login_attempts >= 5:
|
||||||
|
user.locked_until = datetime.utcnow() + timedelta(minutes=15)
|
||||||
|
await db.commit()
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||||
|
|
||||||
|
# Success — reset
|
||||||
|
user.login_attempts = 0
|
||||||
|
user.locked_until = None
|
||||||
|
await db.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def create_user(db: AsyncSession, username: str, email: str, password: str) -> User:
|
||||||
|
result = await db.execute(select(User))
|
||||||
|
existing_count = len(result.scalars().all())
|
||||||
|
|
||||||
|
if existing_count == 0:
|
||||||
|
role = UserRole.OWNER
|
||||||
|
is_approved = True
|
||||||
|
else:
|
||||||
|
role = UserRole.VIEWER
|
||||||
|
is_approved = False
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
hashed_password=hash_password(password),
|
||||||
|
role=role,
|
||||||
|
is_approved=is_approved,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Test fixtures: async client + in-memory SQLite database."""
|
||||||
|
import asyncio
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.database import Base, get_db
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def event_loop():
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
yield loop
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="session")
|
||||||
|
async def engine():
|
||||||
|
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield engine
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db(engine) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
async with session_factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def client(engine) -> AsyncGenerator[AsyncClient, None]:
|
||||||
|
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
async def override_get_db():
|
||||||
|
async with session_factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
app.dependency_overrides.clear()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Auth API tests."""
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegister:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_success(self, client: AsyncClient):
|
||||||
|
resp = await client.post("/api/v1/auth/register", json={
|
||||||
|
"username": "owner1",
|
||||||
|
"email": "owner@test.local",
|
||||||
|
"password": "Pass1234",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert data["username"] == "owner1"
|
||||||
|
assert data["role"] == "owner"
|
||||||
|
assert data["is_approved"] is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_duplicate(self, client: AsyncClient):
|
||||||
|
resp = await client.post("/api/v1/auth/register", json={
|
||||||
|
"username": "owner1",
|
||||||
|
"email": "another@test.local",
|
||||||
|
"password": "Pass1234",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert "Username" in resp.json()["detail"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_second_user_is_viewer(self, client: AsyncClient):
|
||||||
|
resp = await client.post("/api/v1/auth/register", json={
|
||||||
|
"username": "viewer1",
|
||||||
|
"email": "viewer@test.local",
|
||||||
|
"password": "Pass1234",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert data["role"] == "viewer"
|
||||||
|
assert data["is_approved"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogin:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_success(self, client: AsyncClient):
|
||||||
|
resp = await client.post("/api/v1/auth/login", json={
|
||||||
|
"username": "owner1",
|
||||||
|
"password": "Pass1234",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert data["token_type"] == "bearer"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_wrong_password(self, client: AsyncClient):
|
||||||
|
resp = await client.post("/api/v1/auth/login", json={
|
||||||
|
"username": "owner1",
|
||||||
|
"password": "wrongpass",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_locked(self, client: AsyncClient):
|
||||||
|
# Fail 4 more times (already failed once above) → total 5 failures
|
||||||
|
for _ in range(4):
|
||||||
|
await client.post("/api/v1/auth/login", json={
|
||||||
|
"username": "owner1",
|
||||||
|
"password": "wrongpass",
|
||||||
|
})
|
||||||
|
resp = await client.post("/api/v1/auth/login", json={
|
||||||
|
"username": "owner1",
|
||||||
|
"password": "wrongpass",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 423
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthGuard:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_require_auth_no_token(self, client: AsyncClient):
|
||||||
|
resp = await client.post("/api/v1/auth/refresh")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_viewer_cannot_manage_users(self, client: AsyncClient):
|
||||||
|
# Login as viewer1 → token
|
||||||
|
login = await client.post("/api/v1/auth/login", json={
|
||||||
|
"username": "viewer1",
|
||||||
|
"password": "Pass1234",
|
||||||
|
})
|
||||||
|
token = login.json()["access_token"]
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
resp = await client.get("/api/v1/users", headers=headers)
|
||||||
|
assert resp.status_code == 403
|
||||||
Reference in New Issue
Block a user