f1tness_parser/backend/tests/test_api/test_auth.py

289 lines
13 KiB
Python

"""Login, token rotation and who may do what, through the real HTTP API."""
from datetime import date
import pytest
from app.config import settings
from app.core.database.repository import TrainingRepository
from app.core.database.user_repository import UserRepository
from app.core.dto.training import ApproachDTO, ExerciseDTO, TrainingDTO
from app.core.dto.user import UserRole
from app.core.security import hash_password
PASSWORD = "correct horse"
async def make_user(session, email, role=UserRole.trainee, superuser=False, **fields):
repository = UserRepository(session)
user = await repository.create(
email=email, password_hash=hash_password(PASSWORD), role=role, **fields
)
if superuser:
user = await repository.update(user.id, {"is_superuser": True})
return user
async def make_training(session, trainee, trainer=None, exercise="Жим", weight=50):
return await TrainingRepository(session).save(
TrainingDTO(
date=date(2025, 1, 1),
trainee_id=trainee.id,
trainer_id=trainer.id if trainer else None,
exercises=[
ExerciseDTO(name=exercise, approaches=[ApproachDTO(weight=weight, reps=10)])
],
)
)
async def login(http, email, **extra):
return await http.post(
"/api/v1/auth/login/", json={"email": email, "password": PASSWORD, **extra}
)
async def auth_headers(client, email):
token = (await login(client, email)).json()["access_token"]
return {"Authorization": f"Bearer {token}"}
# --- login and tokens ---------------------------------------------------------------------
async def test_login_returns_working_tokens(client, session):
await make_user(session, "a@example.com", first_name="Anna")
response = await login(client, " A@Example.com ")
assert response.status_code == 200
body = response.json()
assert body["token_type"] == "bearer" and body["expires_in"] == 15 * 60
assert body["refresh_token"]
me = await client.get(
"/api/v1/auth/me/", headers={"Authorization": f"Bearer {body['access_token']}"}
)
assert me.json()["email"] == "a@example.com" and "password" not in me.text
assert (await UserRepository(session).list())[0].last_login is not None
async def test_wrong_password_unknown_email_and_inactive_user_are_rejected(client, session):
user = await make_user(session, "a@example.com")
wrong = await client.post(
"/api/v1/auth/login/", json={"email": "a@example.com", "password": "nope"}
)
unknown = await login(client, "nobody@example.com")
assert wrong.status_code == unknown.status_code == 401
assert wrong.json() == unknown.json() # nothing tells which of the two was wrong
await UserRepository(session).update(user.id, {"is_active": False})
assert (await login(client, "a@example.com")).status_code == 401
async def test_web_client_gets_the_refresh_token_only_in_an_httponly_cookie(client, session):
await make_user(session, "a@example.com")
response = await login(client, "a@example.com", client="web")
assert response.json()["refresh_token"] is None
cookie = response.headers["set-cookie"].lower()
assert "refresh_token=" in cookie and "httponly" in cookie and "samesite=strict" in cookie
assert "path=/api/v1/auth" in cookie
refreshed = await client.post("/api/v1/auth/refresh/") # the cookie jar sends it
assert refreshed.status_code == 200
assert refreshed.json()["refresh_token"] is None
assert refreshed.headers["set-cookie"] != response.headers["set-cookie"] # rotated
async def test_refresh_token_works_once_and_reuse_revokes_the_whole_chain(client, session):
await make_user(session, "a@example.com")
first = (await login(client, "a@example.com")).json()["refresh_token"]
second = await client.post("/api/v1/auth/refresh/", json={"refresh_token": first})
assert second.status_code == 200
newest = second.json()["refresh_token"]
assert newest != first
# The old token again: somebody has a copy. Everything issued from it dies.
replay = await client.post("/api/v1/auth/refresh/", json={"refresh_token": first})
assert replay.status_code == 401
after = await client.post("/api/v1/auth/refresh/", json={"refresh_token": newest})
assert after.status_code == 401
async def test_logout_revokes_the_refresh_token(client, session):
await make_user(session, "a@example.com")
refresh_token = (await login(client, "a@example.com")).json()["refresh_token"]
await client.post("/api/v1/auth/logout/", json={"refresh_token": refresh_token})
response = await client.post("/api/v1/auth/refresh/", json={"refresh_token": refresh_token})
assert response.status_code == 401
async def test_refresh_fails_for_a_deactivated_user(client, session):
user = await make_user(session, "a@example.com")
refresh_token = (await login(client, "a@example.com")).json()["refresh_token"]
await UserRepository(session).update(user.id, {"is_active": False})
response = await client.post("/api/v1/auth/refresh/", json={"refresh_token": refresh_token})
assert response.status_code == 401
async def test_missing_bad_and_expired_access_tokens_are_401(client, session, monkeypatch):
await make_user(session, "a@example.com")
headers = await auth_headers(client, "a@example.com")
assert (await client.get("/api/v1/trainings/")).status_code == 401
bad = {"Authorization": headers["Authorization"][:-3] + "abc"}
assert (await client.get("/api/v1/trainings/", headers=bad)).status_code == 401
assert (await client.get("/api/v1/trainings/", headers=headers)).status_code == 200
monkeypatch.setattr(settings, "ACCESS_TOKEN_TTL_MINUTES", -1)
expired = await auth_headers(client, "a@example.com")
assert (await client.get("/api/v1/trainings/", headers=expired)).status_code == 401
async def test_deactivated_user_loses_access_before_the_token_expires(client, session):
user = await make_user(session, "a@example.com")
headers = await auth_headers(client, "a@example.com")
await UserRepository(session).update(user.id, {"is_active": False})
assert (await client.get("/api/v1/trainings/", headers=headers)).status_code == 401
async def test_health_is_open(client):
assert (await client.get("/api/v1/health/")).status_code == 200
# --- who may do what ----------------------------------------------------------------------
async def test_everyone_sees_only_their_trainings(client, session):
anna = await make_user(session, "anna@example.com")
boris = await make_user(session, "boris@example.com")
coach = await make_user(session, "coach@example.com", UserRole.trainer, first_name="Пан")
other_coach = await make_user(session, "coach2@example.com", UserRole.trainer)
admin = await make_user(session, "admin@example.com", superuser=True)
annas = await make_training(session, anna, trainer=coach)
boriss = await make_training(session, boris, trainer=other_coach)
async def visible(email):
response = await client.get(
"/api/v1/trainings/", headers=await auth_headers(client, email)
)
return {item["id"] for item in response.json()}
assert await visible("anna@example.com") == {annas}
assert await visible("boris@example.com") == {boriss}
assert await visible("coach@example.com") == {annas}
assert await visible("admin@example.com") == {annas, boriss}
# a training of someone else doesn't exist for the user
headers = await auth_headers(client, "anna@example.com")
assert (await client.get(f"/api/v1/trainings/{boriss}/", headers=headers)).status_code == 404
assert (await client.get(f"/api/v1/trainings/{annas}/", headers=headers)).status_code == 200
def update_body(trainee_id, trainer_id=None):
return {
"date": "2025-02-02",
"trainee_id": trainee_id,
"trainer_id": trainer_id,
"exercises": [{"name": "Тяга", "approaches": [{"weight": 30, "reps": 8}]}],
}
async def test_only_the_trainee_or_a_superuser_can_change_a_training(client, session):
anna = await make_user(session, "anna@example.com")
boris = await make_user(session, "boris@example.com")
coach = await make_user(session, "coach@example.com", UserRole.trainer)
await make_user(session, "admin@example.com", superuser=True)
training_id = await make_training(session, anna, trainer=coach)
url = f"/api/v1/trainings/{training_id}/"
coach_headers = await auth_headers(client, "coach@example.com")
boris_headers = await auth_headers(client, "boris@example.com")
anna_headers = await auth_headers(client, "anna@example.com")
assert (await client.put(url, json=update_body(anna.id, coach.id), headers=coach_headers)).status_code == 403
assert (await client.delete(url, headers=coach_headers)).status_code == 403
assert (await client.put(url, json=update_body(anna.id), headers=boris_headers)).status_code == 404
assert (await client.delete(url, headers=boris_headers)).status_code == 404
# the trainee can't hand the training over to somebody else, the superuser can
assert (await client.put(url, json=update_body(boris.id), headers=anna_headers)).status_code == 403
ok = await client.put(url, json=update_body(anna.id), headers=anna_headers)
assert ok.status_code == 200 and ok.json()["trainer"] is None
admin_headers = await auth_headers(client, "admin@example.com")
moved = await client.put(url, json=update_body(boris.id), headers=admin_headers)
assert moved.status_code == 200 and moved.json()["trainee_id"] == boris.id
assert (await client.delete(url, headers=admin_headers)).status_code == 200
async def test_user_management_is_for_superusers_only(client, session):
await make_user(session, "anna@example.com")
admin = await make_user(session, "admin@example.com", superuser=True)
anna_headers = await auth_headers(client, "anna@example.com")
admin_headers = await auth_headers(client, "admin@example.com")
new_user = {"email": "new@example.com", "password": "12345678"}
assert (await client.get("/api/v1/users/", headers=anna_headers)).status_code == 403
assert (await client.post("/api/v1/users/", json=new_user, headers=anna_headers)).status_code == 403
assert (await client.post("/api/v1/users/", json=new_user)).status_code == 401
assert (await client.post("/api/v1/users/", json=new_user, headers=admin_headers)).status_code == 201
# the new user can log in with the password the superuser chose
created = await client.post(
"/api/v1/auth/login/", json={"email": "new@example.com", "password": "12345678"}
)
assert created.status_code == 200
# a superuser can't lock themselves out
assert (await client.delete(f"/api/v1/users/{admin.id}/", headers=admin_headers)).status_code == 400
async def test_any_user_can_list_trainers_but_only_by_name(client, session):
await make_user(session, "anna@example.com")
await make_user(session, "coach@example.com", UserRole.trainer, first_name="Пан", last_name="Степан")
response = await client.get(
"/api/v1/users/trainers/", headers=await auth_headers(client, "anna@example.com")
)
assert response.status_code == 200
assert [(t["name"], set(t)) for t in response.json()] == [("Пан Степан", {"id", "name"})]
async def test_exercise_statistics_are_about_the_users_own_trainings(client, session):
anna = await make_user(session, "anna@example.com")
boris = await make_user(session, "boris@example.com")
await make_training(session, anna, exercise="Жим", weight=50)
await make_training(session, boris, exercise="Жим", weight=90)
await make_training(session, boris, exercise="Тяга")
headers = await auth_headers(client, "anna@example.com")
names = await client.get("/api/v1/exercises/", headers=headers)
progress = await client.get("/api/v1/exercises/progress/", params={"name": "Жим"}, headers=headers)
foreign = await client.get("/api/v1/exercises/progress/", params={"name": "Тяга"}, headers=headers)
other_trainee = await client.get(
"/api/v1/exercises/progress/", params={"name": "Жим", "trainee_id": boris.id}, headers=headers
)
assert [n["name"] for n in names.json()] == ["Жим"]
assert progress.json()["max_weight"] == 50 and len(progress.json()["rows"]) == 1
assert foreign.status_code == 404
assert other_trainee.status_code == 404 # Boris' trainings are not visible to Anna
async def test_parser_and_dev_endpoints_need_a_login(client, session):
await make_user(session, "anna@example.com")
headers = await auth_headers(client, "anna@example.com")
body = {"data": "# 07.01.2025 (solo-2)\n", "format": "obsidian"}
assert (await client.post("/api/v1/parser/parse/", json=body)).status_code == 401
assert (await client.post("/api/v1/parser/parse/", json=body, headers=headers)).status_code == 200
assert (await client.get("/api/v1/trainings/obsidian/", headers=headers)).status_code == 403