""" Copyright 2024-2026 ReplyPier Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. Server-side sessions behind the dashboard JWTs, end to end through the real auth dependencies: nothing here overrides get_current_user. """ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from sqlalchemy.orm import Session from uuid import UUID, uuid4 from app.api import users as users_router from app.core.config import settings from app.core.security import create_access_token, create_refresh_token, get_password_hash, verify_token from app.database import Base, get_db from app.models.auth_session import AuthSession from app.models.permission import Permission, role_permissions from app.models.role import Role from app.models.user import User from tests.conftest import engine, TestingSessionLocal, create_tables app = FastAPI() app.include_router(users_router.router, prefix=f"{settings.API_V1_STR}/users") USERS = f"{settings.API_V1_STR}/users" ADMIN_PASSWORD = "Adm1nPassw0rd!" AGENT_PASSWORD = "Ag3ntPassw0rd!" @pytest.fixture(scope="function") def db(): Base.metadata.drop_all(bind=engine) create_tables() session = TestingSessionLocal() try: yield session finally: session.close() Base.metadata.drop_all(bind=engine) @pytest.fixture def role(db: Session, test_organization) -> Role: perm = Permission(name="manage_users", description="Can manage users") db.add(perm) role = Role(name="Admin", organization_id=test_organization.id) db.add(role) db.commit() db.execute(role_permissions.insert().values(role_id=role.id, permission_id=perm.id)) db.commit() return role def _user(db: Session, org, role: Role, email: str, password: str) -> User: user = User( id=uuid4(), email=email, hashed_password=get_password_hash(password), organization_id=org.id, role_id=role.id, is_active=True, full_name=email, ) db.add(user) db.commit() db.refresh(user) return user @pytest.fixture def admin(db, test_organization, role) -> User: return _user(db, test_organization, role, "admin@test.com", ADMIN_PASSWORD) @pytest.fixture def agent(db, test_organization, role) -> User: return _user(db, test_organization, role, "agent@test.com", AGENT_PASSWORD) @pytest.fixture def client(db) -> TestClient: def override_get_db(): session = TestingSessionLocal() try: yield session finally: session.close() users_router.HAS_ENTERPRISE = False app.dependency_overrides[get_db] = override_get_db yield TestClient(app) app.dependency_overrides.clear() def login(client: TestClient, email: str, password: str) -> dict: response = client.post(f"{USERS}/login", data={"username": email, "password": password}) assert response.status_code == 200 # Pass tokens explicitly on each request rather than through the cookie jar. client.cookies.clear() return response.json() def bearer(token: str) -> dict: return {"Authorization": f"Bearer {token}"} def refresh_cookie(token: str) -> dict: return {"Cookie": f"refresh_token={token}"} def can_read_user(client: TestClient, token: str, user: User) -> int: """Status of an authenticated request made with `token`.""" return client.get(f"{USERS}/{user.id}", headers=bearer(token)).status_code def test_login_opens_a_session_both_tokens_share(client, db, admin): tokens = login(client, admin.email, ADMIN_PASSWORD) sid = verify_token(tokens["access_token"])["sid"] assert verify_token(tokens["refresh_token"])["sid"] == sid session = db.query(AuthSession).filter(AuthSession.id == UUID(sid)).one() assert session.user_id == admin.id assert session.revoked_at is None def test_refresh_keeps_the_session(client, db, admin): tokens = login(client, admin.email, ADMIN_PASSWORD) response = client.post(f"{USERS}/refresh", headers=refresh_cookie(tokens["refresh_token"])) assert response.status_code == 200 refreshed = response.json() assert verify_token(refreshed["access_token"])["sid"] == verify_token(tokens["access_token"])["sid"] assert db.query(AuthSession).count() == 1 def test_logout_revokes_the_access_and_refresh_tokens(client, admin): tokens = login(client, admin.email, ADMIN_PASSWORD) assert can_read_user(client, tokens["access_token"], admin) == 200 response = client.post( f"{USERS}/logout", headers={**bearer(tokens["access_token"]), **refresh_cookie(tokens["refresh_token"])}, ) assert response.status_code == 200 # A copy of either token taken before sign-out no longer works assert can_read_user(client, tokens["access_token"], admin) == 401 response = client.post(f"{USERS}/refresh", headers=refresh_cookie(tokens["refresh_token"])) assert response.status_code == 401 def test_logout_leaves_other_sessions_alone(client, admin): laptop = login(client, admin.email, ADMIN_PASSWORD) phone = login(client, admin.email, ADMIN_PASSWORD) client.post(f"{USERS}/logout", headers=bearer(laptop["access_token"])) assert can_read_user(client, laptop["access_token"], admin) == 401 assert can_read_user(client, phone["access_token"], admin) == 200 def test_refresh_token_is_not_an_access_token(client, admin): tokens = login(client, admin.email, ADMIN_PASSWORD) assert can_read_user(client, tokens["refresh_token"], admin) == 401 def test_access_token_is_not_a_refresh_token(client, admin): tokens = login(client, admin.email, ADMIN_PASSWORD) response = client.post(f"{USERS}/refresh", headers=refresh_cookie(tokens["access_token"])) assert response.status_code == 401 def test_legacy_tokens_without_a_session_still_work(client, db, admin): """Tokens issued before sessions existed must not sign anyone out on deploy""" claims = {"sub": str(admin.id), "org": str(admin.organization_id)} assert can_read_user(client, create_access_token(claims), admin) == 200 response = client.post(f"{USERS}/refresh", headers=refresh_cookie(create_refresh_token(claims))) # ...and refreshing one moves it onto a real session assert response.status_code == 200 sid = verify_token(response.json()["access_token"])["sid"] assert verify_token(response.json()["refresh_token"])["sid"] == sid assert db.query(AuthSession).filter(AuthSession.id == UUID(sid)).one().user_id == admin.id def test_password_change_signs_out_other_sessions_only(client, admin): current = login(client, admin.email, ADMIN_PASSWORD) other = login(client, admin.email, ADMIN_PASSWORD) response = client.patch( f"{USERS}/me", json={"current_password": ADMIN_PASSWORD, "password": "N3wPassw0rd!"}, headers=bearer(current["access_token"]), ) assert response.status_code == 200 assert can_read_user(client, current["access_token"], admin) == 200 assert can_read_user(client, other["access_token"], admin) == 401 response = client.post(f"{USERS}/refresh", headers=refresh_cookie(other["refresh_token"])) assert response.status_code == 401 def test_admin_password_reset_signs_out_the_target(client, admin, agent): admin_tokens = login(client, admin.email, ADMIN_PASSWORD) agent_tokens = login(client, agent.email, AGENT_PASSWORD) response = client.post( f"{USERS}/{agent.id}/reset-password", json={"new_password": "N3wPassw0rd!"}, headers=bearer(admin_tokens["access_token"]), ) assert response.status_code == 200 assert can_read_user(client, agent_tokens["access_token"], agent) == 401 response = client.post(f"{USERS}/refresh", headers=refresh_cookie(agent_tokens["refresh_token"])) assert response.status_code == 401 assert can_read_user(client, admin_tokens["access_token"], admin) == 200 def test_deactivating_a_user_revokes_their_sessions(client, db, admin, agent): admin_tokens = login(client, admin.email, ADMIN_PASSWORD) login(client, agent.email, AGENT_PASSWORD) response = client.put( f"{USERS}/{agent.id}", json={"is_active": False}, headers=bearer(admin_tokens["access_token"]), ) assert response.status_code == 200 sessions = db.query(AuthSession).filter(AuthSession.user_id == agent.id).all() assert sessions and all(s.revoked_at is not None for s in sessions)