ac76c0a395
login_user riusa l'api_key esistente invece di rigenerarla ad ogni
login: due postazioni collegate con lo stesso utente restavano
altrimenti disconnesse a vicenda. logout_user diventa no-op sulla key
a meno di all_sessions=True (nuovo parametro, passato da
POST /api/auth/logout?all_sessions=true nel router).
authenticate_user esegue comunque bcrypt.checkpw contro un hash fittizio
quando lo username non esiste, per non rivelare col tempo di risposta
se uno username è registrato.
PUT /api/users/{id}/password azzera l'api_key dell'utente dopo il
cambio, come già fa setup.py:634.
Non introdotto in questa issue: hashing o scadenza della api_key
(annotato come MANCA nel rapporto del lotto).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
188 lines
6.8 KiB
Python
188 lines
6.8 KiB
Python
"""Tests for authentication router (/api/auth)."""
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
from src.backend.tests.conftest import _create_user, auth_headers
|
|
|
|
|
|
class TestLogin:
|
|
"""POST /api/auth/login tests."""
|
|
|
|
async def test_login_success(self, client: AsyncClient, db_session):
|
|
"""Successful login returns user data and API key."""
|
|
user = await _create_user(
|
|
db_session, username="loginuser", password="SecurePass1"
|
|
)
|
|
resp = await client.post(
|
|
"/api/auth/login",
|
|
json={"username": "loginuser", "password": "SecurePass1"},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["user"]["username"] == "loginuser"
|
|
assert "api_key" in data
|
|
assert len(data["api_key"]) > 0
|
|
|
|
async def test_login_wrong_password(self, client: AsyncClient, db_session):
|
|
"""Login with wrong password returns 401."""
|
|
await _create_user(
|
|
db_session, username="wrongpw", password="CorrectPass1"
|
|
)
|
|
resp = await client.post(
|
|
"/api/auth/login",
|
|
json={"username": "wrongpw", "password": "WrongPass1"},
|
|
)
|
|
assert resp.status_code == 401
|
|
assert "Invalid" in resp.json()["detail"]
|
|
|
|
async def test_login_unknown_user(self, client: AsyncClient):
|
|
"""Login with non-existent username returns 401."""
|
|
resp = await client.post(
|
|
"/api/auth/login",
|
|
json={"username": "nonexistent", "password": "Anything1"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
async def test_login_inactive_user(self, client: AsyncClient, db_session):
|
|
"""Login with inactive user returns 401."""
|
|
await _create_user(
|
|
db_session,
|
|
username="inactive_user",
|
|
password="InactivePass1",
|
|
active=False,
|
|
)
|
|
resp = await client.post(
|
|
"/api/auth/login",
|
|
json={"username": "inactive_user", "password": "InactivePass1"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
async def test_login_missing_fields(self, client: AsyncClient):
|
|
"""Login with missing fields returns 422."""
|
|
resp = await client.post("/api/auth/login", json={"username": "only"})
|
|
assert resp.status_code == 422
|
|
|
|
async def test_login_returns_api_key(self, client: AsyncClient, db_session):
|
|
"""Login response contains a valid api_key string."""
|
|
await _create_user(
|
|
db_session, username="apikey_user", password="KeyPass123"
|
|
)
|
|
resp = await client.post(
|
|
"/api/auth/login",
|
|
json={"username": "apikey_user", "password": "KeyPass123"},
|
|
)
|
|
assert resp.status_code == 200
|
|
api_key = resp.json()["api_key"]
|
|
# API key should be a non-empty string (base64url, ~64 chars)
|
|
assert isinstance(api_key, str)
|
|
assert len(api_key) >= 32
|
|
|
|
|
|
class TestLoginKeyReuse:
|
|
"""login_user reuses an existing api_key instead of rotating it (#25)."""
|
|
|
|
async def test_two_logins_same_user_share_api_key(
|
|
self, client: AsyncClient, db_session
|
|
):
|
|
"""Two stations logging in as the same shift account both keep working."""
|
|
await _create_user(db_session, username="twostations", password="TwoStations1")
|
|
resp1 = await client.post(
|
|
"/api/auth/login",
|
|
json={"username": "twostations", "password": "TwoStations1"},
|
|
)
|
|
resp2 = await client.post(
|
|
"/api/auth/login",
|
|
json={"username": "twostations", "password": "TwoStations1"},
|
|
)
|
|
assert resp1.status_code == 200
|
|
assert resp2.status_code == 200
|
|
assert resp1.json()["api_key"] == resp2.json()["api_key"]
|
|
|
|
async def test_login_generates_key_when_user_has_none(
|
|
self, client: AsyncClient, db_session
|
|
):
|
|
"""A user created without an api_key (e.g. via create_user) still gets one."""
|
|
from src.backend.services.auth_service import create_user
|
|
|
|
await create_user(
|
|
db_session, username="brandnew", password="BrandNew1",
|
|
display_name="Brand New",
|
|
)
|
|
resp = await client.post(
|
|
"/api/auth/login",
|
|
json={"username": "brandnew", "password": "BrandNew1"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["api_key"]
|
|
|
|
|
|
class TestLogout:
|
|
"""POST /api/auth/logout is a no-op on the shared api_key by default (#25)."""
|
|
|
|
async def test_logout_default_keeps_api_key_valid(
|
|
self, client: AsyncClient, db_session
|
|
):
|
|
user = await _create_user(
|
|
db_session, username="shared_logout", password="SharedPass1"
|
|
)
|
|
headers = auth_headers(user)
|
|
resp = await client.post("/api/auth/logout", headers=headers)
|
|
assert resp.status_code == 204
|
|
|
|
# The key is still valid: another station on the same account keeps working.
|
|
resp = await client.get("/api/auth/me", headers=headers)
|
|
assert resp.status_code == 200
|
|
|
|
async def test_logout_all_sessions_invalidates_api_key(
|
|
self, client: AsyncClient, db_session
|
|
):
|
|
user = await _create_user(
|
|
db_session, username="shared_logout_all", password="SharedPass1"
|
|
)
|
|
headers = auth_headers(user)
|
|
resp = await client.post(
|
|
"/api/auth/logout", params={"all_sessions": "true"}, headers=headers
|
|
)
|
|
assert resp.status_code == 204
|
|
|
|
resp = await client.get("/api/auth/me", headers=headers)
|
|
assert resp.status_code == 401
|
|
|
|
|
|
class TestAuthenticateUserTiming:
|
|
"""authenticate_user pays the bcrypt cost even for an unknown username (#25)."""
|
|
|
|
async def test_unknown_username_still_returns_none(self, db_session):
|
|
from src.backend.services.auth_service import authenticate_user
|
|
|
|
result = await authenticate_user(db_session, "no-such-user-at-all", "whatever")
|
|
assert result is None
|
|
|
|
def test_dummy_hash_is_a_valid_bcrypt_hash(self):
|
|
from src.backend.services.auth_service import (
|
|
_DUMMY_PASSWORD_HASH,
|
|
verify_password,
|
|
)
|
|
|
|
assert verify_password(
|
|
"tiemeasureflow-dummy-hash-for-timing", _DUMMY_PASSWORD_HASH
|
|
)
|
|
|
|
|
|
class TestHealthAndAuth:
|
|
"""Health check and auth requirement tests."""
|
|
|
|
async def test_health_check_no_auth(self, client: AsyncClient):
|
|
"""Health check endpoint works without authentication."""
|
|
resp = await client.get("/api/health")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["status"] == "ok"
|
|
assert "version" in data
|
|
|
|
async def test_endpoint_requires_api_key(self, client: AsyncClient):
|
|
"""Protected endpoint returns 401 without API key."""
|
|
resp = await client.get("/api/users")
|
|
assert resp.status_code == 401
|
|
assert "API key" in resp.json()["detail"] or "Missing" in resp.json()["detail"]
|