feat(auth): implement auth stack
This commit is contained in:
65
tests/api/helpers.py
Normal file
65
tests/api/helpers.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Shared helpers for API integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import asyncpg
|
||||
from httpx import AsyncClient
|
||||
|
||||
API_PREFIX = "/v1"
|
||||
|
||||
|
||||
async def register_user(
|
||||
client: AsyncClient,
|
||||
*,
|
||||
email: str,
|
||||
password: str,
|
||||
org_name: str = "Test Org",
|
||||
) -> dict[str, Any]:
|
||||
"""Call the register endpoint and return JSON body (raises on failure)."""
|
||||
|
||||
response = await client.post(
|
||||
f"{API_PREFIX}/auth/register",
|
||||
json={"email": email, "password": password, "org_name": org_name},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
async def create_org(
|
||||
conn: asyncpg.Connection,
|
||||
*,
|
||||
name: str,
|
||||
slug: str | None = None,
|
||||
) -> UUID:
|
||||
"""Insert an organization row and return its ID."""
|
||||
|
||||
org_id = uuid4()
|
||||
slug_value = slug or name.lower().replace(" ", "-")
|
||||
await conn.execute(
|
||||
"INSERT INTO orgs (id, name, slug) VALUES ($1, $2, $3)",
|
||||
org_id,
|
||||
name,
|
||||
slug_value,
|
||||
)
|
||||
return org_id
|
||||
|
||||
|
||||
async def add_membership(
|
||||
conn: asyncpg.Connection,
|
||||
*,
|
||||
user_id: UUID,
|
||||
org_id: UUID,
|
||||
role: str,
|
||||
) -> None:
|
||||
"""Insert a membership record for the user/org pair."""
|
||||
|
||||
await conn.execute(
|
||||
"INSERT INTO org_members (id, user_id, org_id, role) VALUES ($1, $2, $3, $4)",
|
||||
uuid4(),
|
||||
user_id,
|
||||
org_id,
|
||||
role,
|
||||
)
|
||||
213
tests/api/test_auth.py
Normal file
213
tests/api/test_auth.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Integration tests for FastAPI auth endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
import asyncpg
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.core import security
|
||||
from tests.api import helpers
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
API_PREFIX = "/v1/auth"
|
||||
|
||||
|
||||
async def test_register_endpoint_persists_user_and_membership(
|
||||
api_client: AsyncClient,
|
||||
db_admin: asyncpg.Connection,
|
||||
) -> None:
|
||||
data = await helpers.register_user(
|
||||
api_client,
|
||||
email="api-register@example.com",
|
||||
password="SuperSecret1!",
|
||||
org_name="API Org",
|
||||
)
|
||||
assert "access_token" in data and "refresh_token" in data
|
||||
|
||||
token_payload = security.decode_access_token(data["access_token"])
|
||||
assert token_payload["org_role"] == "admin"
|
||||
|
||||
stored_user = await db_admin.fetchrow("SELECT email FROM users WHERE email = $1", "api-register@example.com")
|
||||
assert stored_user is not None
|
||||
|
||||
membership = await db_admin.fetchrow(
|
||||
"SELECT role FROM org_members WHERE user_id = $1 AND org_id = $2",
|
||||
UUID(token_payload["sub"]),
|
||||
UUID(token_payload["org_id"]),
|
||||
)
|
||||
assert membership is not None and membership["role"] == "admin"
|
||||
|
||||
|
||||
async def test_login_endpoint_rejects_bad_credentials(
|
||||
api_client: AsyncClient,
|
||||
) -> None:
|
||||
register_payload = {
|
||||
"email": "api-login@example.com",
|
||||
"password": "CorrectHorse1!",
|
||||
"org_name": "Login Org",
|
||||
}
|
||||
await helpers.register_user(api_client, **register_payload)
|
||||
|
||||
response = await api_client.post(
|
||||
f"{API_PREFIX}/login",
|
||||
json={"email": register_payload["email"], "password": "wrong"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_refresh_endpoint_rotates_refresh_token(
|
||||
api_client: AsyncClient,
|
||||
db_admin: asyncpg.Connection,
|
||||
) -> None:
|
||||
register_payload = {
|
||||
"email": "api-refresh@example.com",
|
||||
"password": "RefreshPass1!",
|
||||
"org_name": "Refresh Org",
|
||||
}
|
||||
initial = await helpers.register_user(api_client, **register_payload)
|
||||
|
||||
response = await api_client.post(
|
||||
f"{API_PREFIX}/refresh",
|
||||
json={"refresh_token": initial["refresh_token"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["refresh_token"] != initial["refresh_token"]
|
||||
|
||||
old_hash = security.hash_token(initial["refresh_token"])
|
||||
old_row = await db_admin.fetchrow(
|
||||
"SELECT rotated_to FROM refresh_tokens WHERE token_hash = $1",
|
||||
old_hash,
|
||||
)
|
||||
assert old_row is not None and old_row["rotated_to"] is not None
|
||||
|
||||
|
||||
async def test_refresh_endpoint_detects_reuse(
|
||||
api_client: AsyncClient,
|
||||
db_admin: asyncpg.Connection,
|
||||
) -> None:
|
||||
tokens = await helpers.register_user(
|
||||
api_client,
|
||||
email="api-reuse@example.com",
|
||||
password="ReusePass1!",
|
||||
org_name="Reuse Org",
|
||||
)
|
||||
|
||||
rotated = await api_client.post(
|
||||
f"{API_PREFIX}/refresh",
|
||||
json={"refresh_token": tokens["refresh_token"]},
|
||||
)
|
||||
assert rotated.status_code == 200
|
||||
|
||||
reuse_response = await api_client.post(
|
||||
f"{API_PREFIX}/refresh",
|
||||
json={"refresh_token": tokens["refresh_token"]},
|
||||
)
|
||||
assert reuse_response.status_code == 401
|
||||
|
||||
old_hash = security.hash_token(tokens["refresh_token"])
|
||||
old_row = await db_admin.fetchrow(
|
||||
"SELECT revoked_at FROM refresh_tokens WHERE token_hash = $1",
|
||||
old_hash,
|
||||
)
|
||||
assert old_row is not None and old_row["revoked_at"] is not None
|
||||
|
||||
|
||||
async def test_switch_org_changes_active_org(
|
||||
api_client: AsyncClient,
|
||||
db_admin: asyncpg.Connection,
|
||||
) -> None:
|
||||
email = "api-switch@example.com"
|
||||
register_payload = {
|
||||
"email": email,
|
||||
"password": "SwitchPass1!",
|
||||
"org_name": "Primary Org",
|
||||
}
|
||||
tokens = await helpers.register_user(api_client, **register_payload)
|
||||
|
||||
user_id_row = await db_admin.fetchrow("SELECT id FROM users WHERE email = $1", email)
|
||||
assert user_id_row is not None
|
||||
user_id = user_id_row["id"]
|
||||
|
||||
target_org_id = await helpers.create_org(db_admin, name="Secondary Org", slug="secondary-org")
|
||||
await helpers.add_membership(db_admin, user_id=user_id, org_id=target_org_id, role="member")
|
||||
|
||||
response = await api_client.post(
|
||||
f"{API_PREFIX}/switch-org",
|
||||
json={"org_id": str(target_org_id), "refresh_token": tokens["refresh_token"]},
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
payload = security.decode_access_token(data["access_token"])
|
||||
assert payload["org_id"] == str(target_org_id)
|
||||
assert payload["org_role"] == "member"
|
||||
|
||||
new_hash = security.hash_token(data["refresh_token"])
|
||||
new_row = await db_admin.fetchrow(
|
||||
"SELECT active_org_id FROM refresh_tokens WHERE token_hash = $1",
|
||||
new_hash,
|
||||
)
|
||||
assert new_row is not None and new_row["active_org_id"] == target_org_id
|
||||
|
||||
|
||||
async def test_switch_org_forbidden_without_membership(
|
||||
api_client: AsyncClient,
|
||||
db_admin: asyncpg.Connection,
|
||||
) -> None:
|
||||
tokens = await helpers.register_user(
|
||||
api_client,
|
||||
email="api-switch-no-access@example.com",
|
||||
password="SwitchBlock1!",
|
||||
org_name="Primary",
|
||||
)
|
||||
|
||||
foreign_org = await helpers.create_org(db_admin, name="Foreign Org", slug="foreign-org")
|
||||
|
||||
response = await api_client.post(
|
||||
f"{API_PREFIX}/switch-org",
|
||||
json={"org_id": str(foreign_org), "refresh_token": tokens["refresh_token"]},
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
# ensure refresh token still valid after failed attempt
|
||||
retry = await api_client.post(
|
||||
f"{API_PREFIX}/refresh",
|
||||
json={"refresh_token": tokens["refresh_token"]},
|
||||
)
|
||||
assert retry.status_code == 200
|
||||
|
||||
|
||||
async def test_logout_revokes_refresh_token(
|
||||
api_client: AsyncClient,
|
||||
) -> None:
|
||||
register_payload = {
|
||||
"email": "api-logout@example.com",
|
||||
"password": "LogoutPass1!",
|
||||
"org_name": "Logout Org",
|
||||
}
|
||||
tokens = await helpers.register_user(api_client, **register_payload)
|
||||
|
||||
logout_response = await api_client.post(
|
||||
f"{API_PREFIX}/logout",
|
||||
json={"refresh_token": tokens["refresh_token"]},
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||
)
|
||||
|
||||
assert logout_response.status_code == 204
|
||||
|
||||
refresh_response = await api_client.post(
|
||||
f"{API_PREFIX}/refresh",
|
||||
json={"refresh_token": tokens["refresh_token"]},
|
||||
)
|
||||
|
||||
assert refresh_response.status_code == 401
|
||||
Reference in New Issue
Block a user