test: 补充权限、消息与会话服务回归用例
会话读取、接管、重分配与 Socket 订阅统一校验角色、组织与可见性。
This commit is contained in:
1 parent
8e3a4d95da
commit
164f5f9615
97 files changed
+37281
No files matched your search
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
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)
|
||||
Reference in new issue
Block a user