2026-06-03 23:52:05 +00:00
|
|
|
"""User /me endpoint tests + RBAC verification."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from httpx import AsyncClient
|
|
|
|
|
|
|
|
|
|
# === /users/me ===
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_get_me_returns_user_data(
|
|
|
|
|
client: AsyncClient, auth_headers: dict[str, str], registered_user: dict
|
|
|
|
|
) -> None:
|
|
|
|
|
"""GET /api/v1/users/me with a valid JWT returns 200 + email, name, role, org_id."""
|
|
|
|
|
resp = await client.get("/api/v1/users/me", headers=auth_headers)
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
data = resp.json()
|
|
|
|
|
assert data["email"] == registered_user["email"]
|
|
|
|
|
assert data["name"] == registered_user["name"]
|
|
|
|
|
assert "role" in data
|
|
|
|
|
assert "org_id" in data
|
|
|
|
|
assert "id" in data
|
|
|
|
|
assert "password_hash" not in data
|
|
|
|
|
assert "avatar_url" in data
|
|
|
|
|
assert "email_notifications" in data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_get_me_invalid_token_format(client: AsyncClient) -> None:
|
|
|
|
|
"""GET /api/v1/users/me with a malformed token returns 401."""
|
|
|
|
|
resp = await client.get(
|
|
|
|
|
"/api/v1/users/me",
|
|
|
|
|
headers={"Authorization": "Bearer this-is-not-a-jwt"},
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 401, resp.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_get_me_bogus_token(client: AsyncClient) -> None:
|
|
|
|
|
"""GET /api/v1/users/me with a token signed with the wrong key returns 401."""
|
|
|
|
|
import time
|
|
|
|
|
|
2026-06-10 21:24:24 +00:00
|
|
|
from jose import jwt
|
|
|
|
|
|
2026-06-03 23:52:05 +00:00
|
|
|
bogus = jwt.encode(
|
|
|
|
|
{
|
|
|
|
|
"sub": "1",
|
|
|
|
|
"org_id": 1,
|
|
|
|
|
"role": "admin",
|
|
|
|
|
"exp": int(time.time()) + 3600,
|
|
|
|
|
"iat": int(time.time()),
|
|
|
|
|
},
|
|
|
|
|
"completely-different-secret-key-32chars-abc",
|
|
|
|
|
algorithm="HS256",
|
|
|
|
|
)
|
|
|
|
|
resp = await client.get(
|
|
|
|
|
"/api/v1/users/me",
|
|
|
|
|
headers={"Authorization": f"Bearer {bogus}"},
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 401, resp.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# === PATCH /users/{id} ===
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_update_profile_self(
|
|
|
|
|
client: AsyncClient, auth_headers: dict[str, str], registered_user: dict
|
|
|
|
|
) -> None:
|
|
|
|
|
"""PATCH /api/v1/users/{self_id} updates the current user's name."""
|
|
|
|
|
user_id = registered_user["user"]["id"]
|
|
|
|
|
resp = await client.patch(
|
|
|
|
|
f"/api/v1/users/{user_id}",
|
|
|
|
|
json={"name": "Updated Name", "email_notifications": False},
|
|
|
|
|
headers=auth_headers,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
data = resp.json()
|
|
|
|
|
assert data["name"] == "Updated Name"
|
|
|
|
|
assert data["email_notifications"] is False
|
|
|
|
|
# email and id should be unchanged
|
|
|
|
|
assert data["email"] == registered_user["email"]
|
|
|
|
|
assert data["id"] == user_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_update_profile_cannot_change_other(
|
|
|
|
|
client: AsyncClient, auth_headers: dict[str, str], registered_user: dict
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Non-admin cannot update another user's profile (403)."""
|
|
|
|
|
# Bootstrap user IS admin (first registered user). To test the 403 case,
|
|
|
|
|
# create a sales_rep and try to update someone else.
|
|
|
|
|
# First, create a 2nd user (admin can do this)
|
|
|
|
|
create_resp = await client.post(
|
|
|
|
|
"/api/v1/users/",
|
|
|
|
|
json={
|
|
|
|
|
"email": "rep@test.com",
|
|
|
|
|
"password": "RepPass123!",
|
|
|
|
|
"name": "Test Rep",
|
|
|
|
|
"role": "sales_rep",
|
|
|
|
|
},
|
|
|
|
|
headers=auth_headers,
|
|
|
|
|
)
|
|
|
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
|
other_id = create_resp.json()["id"]
|
|
|
|
|
|
|
|
|
|
# Now log in as the rep and try to update the admin
|
|
|
|
|
login_resp = await client.post(
|
|
|
|
|
"/api/v1/auth/login",
|
|
|
|
|
data={"username": "rep@test.com", "password": "RepPass123!"},
|
|
|
|
|
)
|
|
|
|
|
assert login_resp.status_code == 200, login_resp.text
|
|
|
|
|
rep_token = login_resp.json()["access_token"]
|
|
|
|
|
rep_headers = {"Authorization": f"Bearer {rep_token}"}
|
|
|
|
|
|
|
|
|
|
# Rep tries to update the admin → 403
|
|
|
|
|
admin_id = registered_user["user"]["id"]
|
|
|
|
|
resp = await client.patch(
|
|
|
|
|
f"/api/v1/users/{admin_id}",
|
|
|
|
|
json={"name": "Hacked"},
|
|
|
|
|
headers=rep_headers,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 403, resp.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# === GET /users/ (admin list) ===
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_list_users_as_admin(
|
|
|
|
|
client: AsyncClient, auth_headers: dict[str, str], registered_user: dict
|
|
|
|
|
) -> None:
|
|
|
|
|
"""GET /api/v1/users/ as admin returns 200 + paginated list."""
|
|
|
|
|
resp = await client.get("/api/v1/users/", headers=auth_headers)
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
data = resp.json()
|
|
|
|
|
assert "items" in data
|
|
|
|
|
assert "total" in data
|
|
|
|
|
assert "page" in data
|
|
|
|
|
assert "page_size" in data
|
|
|
|
|
assert data["total"] >= 1
|
|
|
|
|
assert len(data["items"]) >= 1
|
|
|
|
|
# The bootstrap user is in the list
|
|
|
|
|
emails = [u["email"] for u in data["items"]]
|
|
|
|
|
assert registered_user["email"] in emails
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_list_users_as_sales_rep_forbidden(
|
|
|
|
|
client: AsyncClient, auth_headers: dict[str, str]
|
|
|
|
|
) -> None:
|
|
|
|
|
"""GET /api/v1/users/ as sales_rep → 403."""
|
|
|
|
|
# Create a sales_rep via the admin
|
|
|
|
|
create_resp = await client.post(
|
|
|
|
|
"/api/v1/users/",
|
|
|
|
|
json={
|
|
|
|
|
"email": "rep@test.com",
|
|
|
|
|
"password": "RepPass123!",
|
|
|
|
|
"name": "Test Rep",
|
|
|
|
|
"role": "sales_rep",
|
|
|
|
|
},
|
|
|
|
|
headers=auth_headers,
|
|
|
|
|
)
|
|
|
|
|
assert create_resp.status_code == 201, create_resp.text
|
|
|
|
|
|
|
|
|
|
# Log in as the rep
|
|
|
|
|
login_resp = await client.post(
|
|
|
|
|
"/api/v1/auth/login",
|
|
|
|
|
data={"username": "rep@test.com", "password": "RepPass123!"},
|
|
|
|
|
)
|
|
|
|
|
assert login_resp.status_code == 200, login_resp.text
|
|
|
|
|
rep_token = login_resp.json()["access_token"]
|
|
|
|
|
rep_headers = {"Authorization": f"Bearer {rep_token}"}
|
|
|
|
|
|
|
|
|
|
# Rep tries to list all users → 403
|
|
|
|
|
resp = await client.get("/api/v1/users/", headers=rep_headers)
|
|
|
|
|
assert resp.status_code == 403, resp.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# === Refresh & Logout ===
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 21:24:24 +00:00
|
|
|
async def test_refresh_token(client: AsyncClient, auth_headers: dict[str, str]) -> None:
|
2026-06-03 23:52:05 +00:00
|
|
|
"""POST /api/v1/auth/refresh issues a new token for the current user."""
|
|
|
|
|
resp = await client.post("/api/v1/auth/refresh", headers=auth_headers)
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
data = resp.json()
|
|
|
|
|
assert "access_token" in data
|
|
|
|
|
assert data["token_type"] == "bearer"
|
|
|
|
|
assert data["expires_in"] > 0
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 21:24:24 +00:00
|
|
|
async def test_logout(client: AsyncClient, auth_headers: dict[str, str]) -> None:
|
2026-06-03 23:52:05 +00:00
|
|
|
"""POST /api/v1/auth/logout returns 200 with confirmation message."""
|
|
|
|
|
resp = await client.post("/api/v1/auth/logout", headers=auth_headers)
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
data = resp.json()
|
|
|
|
|
assert data["message"] == "logged out"
|