289 lines
13 KiB
Python
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
|