"""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