334 lines
14 KiB
Python
334 lines
14 KiB
Python
"""
|
|
tests/test_api_auth.py
|
|
----------------------
|
|
Tests for /api/auth/* endpoints.
|
|
All external OAuth calls are mocked with respx.
|
|
"""
|
|
|
|
import pytest
|
|
import respx
|
|
import httpx
|
|
from fastapi.testclient import TestClient
|
|
|
|
from web.app import app
|
|
from web.routers.auth import _ADOBE_TOKEN_URL
|
|
from web.session import create_test_session
|
|
|
|
client = TestClient(app, raise_server_exceptions=True)
|
|
|
|
|
|
def _userinfo_payload():
|
|
return {
|
|
"name": "Paul Example",
|
|
"email": "paul@example.com",
|
|
"accounts": [
|
|
{
|
|
"account_id": "bbb-account",
|
|
"account_name": "Zulu Team",
|
|
"base_uri": "https://na3.docusign.net",
|
|
"is_default": False,
|
|
},
|
|
{
|
|
"account_id": "aaa-account",
|
|
"account_name": "Alpha Team",
|
|
"base_uri": "https://na2.docusign.net",
|
|
"is_default": True,
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def temp_session_store(tmp_path, monkeypatch):
|
|
import web.config as cfg
|
|
monkeypatch.setattr(cfg.settings, "session_store_dir", str(tmp_path / ".session-store"))
|
|
monkeypatch.setattr(cfg.settings, "adobe_redirect_uri", "http://localhost:8000/api/auth/adobe/callback")
|
|
client.cookies.clear()
|
|
yield
|
|
client.cookies.clear()
|
|
|
|
|
|
def test_status_unauthenticated():
|
|
"""Fresh session → both platforms disconnected."""
|
|
resp = client.get("/api/auth/status", cookies={})
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["adobe"] is False
|
|
assert data["docusign"] is False
|
|
|
|
|
|
def test_adobe_url_returns_auth_url():
|
|
"""GET /api/auth/adobe/url returns an Adobe Sign authorization URL."""
|
|
resp = client.get("/api/auth/adobe/url")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "url" in data
|
|
assert "adobesign.com" in data["url"]
|
|
assert "response_type=code" in data["url"]
|
|
assert "redirect_uri=http://localhost:8000/api/auth/adobe/callback" in data["url"]
|
|
assert resp.cookies.get("migrator_session") is not None
|
|
|
|
|
|
def test_adobe_connect_env_stores_token(monkeypatch):
|
|
"""GET /api/auth/adobe/connect uses .env refresh token → session connected."""
|
|
monkeypatch.setenv("ADOBE_ACCESS_TOKEN", "existing-token")
|
|
monkeypatch.setenv("ADOBE_REFRESH_TOKEN", "existing-refresh")
|
|
|
|
from unittest.mock import patch
|
|
with patch("adobe_api._refresh_access_token", return_value="refreshed-token"), \
|
|
patch("web.routers.auth._fetch_adobe_profile", return_value={
|
|
"adobe_user_name": "Paul Adobe",
|
|
"adobe_user_email": "paul@example.com",
|
|
"adobe_account_name": "Paul Sandbox",
|
|
"adobe_account_id": "adobe-account-123",
|
|
}):
|
|
resp = client.get("/api/auth/adobe/connect")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json()["connected"] is True
|
|
session_cookie = resp.cookies.get("migrator_session")
|
|
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
|
|
assert status_resp.json()["adobe"] is True
|
|
assert status_resp.json()["adobe_account_name"] == "Paul Sandbox"
|
|
assert status_resp.json()["adobe_account_id"] == "adobe-account-123"
|
|
assert status_resp.json()["adobe_label"] == "Paul Sandbox"
|
|
|
|
|
|
def test_adobe_connect_requests_authorization_without_credentials(monkeypatch):
|
|
"""GET /api/auth/adobe/connect with no .env tokens returns an auth URL."""
|
|
monkeypatch.delenv("ADOBE_ACCESS_TOKEN", raising=False)
|
|
monkeypatch.delenv("ADOBE_REFRESH_TOKEN", raising=False)
|
|
resp = client.get("/api/auth/adobe/connect")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["authorization_required"] is True
|
|
assert "/api/auth/adobe/callback" in resp.json()["authorization_url"]
|
|
|
|
|
|
@respx.mock
|
|
def test_adobe_exchange_stores_token():
|
|
"""POST /api/auth/adobe/exchange with a valid redirect URL → session connected."""
|
|
respx.post(_ADOBE_TOKEN_URL).mock(
|
|
return_value=httpx.Response(200, json={
|
|
"access_token": "adobe-test-token",
|
|
"refresh_token": "adobe-refresh",
|
|
})
|
|
)
|
|
|
|
resp = client.post(
|
|
"/api/auth/adobe/exchange",
|
|
json={"redirect_url": "https://localhost:8080/callback?code=authcode123&api_access_point=https://api.eu2.adobesign.com/"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["connected"] is True
|
|
|
|
session_cookie = resp.cookies.get("migrator_session")
|
|
assert session_cookie is not None
|
|
|
|
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
|
|
assert status_resp.json()["adobe"] is True
|
|
|
|
|
|
@respx.mock
|
|
def test_adobe_callback_stores_session_tokens():
|
|
"""GET /api/auth/adobe/callback stores tokens in this browser session."""
|
|
respx.post(_ADOBE_TOKEN_URL).mock(
|
|
return_value=httpx.Response(200, json={
|
|
"access_token": "adobe-test-token",
|
|
"refresh_token": "adobe-refresh",
|
|
})
|
|
)
|
|
|
|
from unittest.mock import patch
|
|
|
|
cookie = create_test_session({"adobe_oauth_state": "expected-state"})
|
|
with patch("web.routers.auth._fetch_adobe_profile", return_value={
|
|
"adobe_user_name": "Paul Adobe",
|
|
"adobe_user_email": "paul@example.com",
|
|
"adobe_account_name": "Paul Sandbox",
|
|
"adobe_account_id": "adobe-account-123",
|
|
}):
|
|
resp = client.get(
|
|
"/api/auth/adobe/callback?code=authcode123&state=expected-state",
|
|
cookies={"migrator_session": cookie},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert resp.status_code in (302, 307)
|
|
session_cookie = resp.cookies.get("migrator_session")
|
|
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
|
|
assert status_resp.json()["adobe"] is True
|
|
assert status_resp.json()["adobe_auth_mode"] == "session_oauth"
|
|
|
|
|
|
def test_adobe_callback_requires_matching_state():
|
|
cookie = create_test_session({"adobe_oauth_state": "expected-state"})
|
|
resp = client.get(
|
|
"/api/auth/adobe/callback?code=authcode123&state=wrong-state",
|
|
cookies={"migrator_session": cookie},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "invalid oauth state" in resp.json()["error"]
|
|
|
|
|
|
def test_adobe_exchange_rejects_missing_code():
|
|
"""POST /api/auth/adobe/exchange with no code in URL → 400."""
|
|
resp = client.post(
|
|
"/api/auth/adobe/exchange",
|
|
json={"redirect_url": "https://localhost:8080/callback?error=access_denied"},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_docusign_connect_stores_token():
|
|
"""GET /api/auth/docusign/connect refreshes the current session's token."""
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
cookie = create_test_session({"docusign_refresh_token": "refresh-123"})
|
|
with patch("docusign_auth.refresh_access_token", return_value={"access_token": "ds-oauth-token", "refresh_token": "refresh-456", "expires_in": 3600}), \
|
|
patch("web.routers.auth.fetch_userinfo", new=AsyncMock(return_value=_userinfo_payload())):
|
|
resp = client.get("/api/auth/docusign/connect", cookies={"migrator_session": cookie})
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json()["connected"] is True
|
|
assert resp.json()["account_selection_required"] is True
|
|
|
|
session_cookie = resp.cookies.get("migrator_session")
|
|
assert session_cookie is not None
|
|
|
|
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
|
|
assert status_resp.json()["docusign"] is True
|
|
assert status_resp.json()["docusign_account_selection_required"] is True
|
|
|
|
|
|
def test_docusign_connect_requests_authorization_when_refresh_token_missing():
|
|
"""GET /api/auth/docusign/connect returns a session-scoped auth URL when auth is needed."""
|
|
from unittest.mock import patch
|
|
|
|
with patch("docusign_auth.build_authorization_url", return_value="https://example.com/oauth"):
|
|
resp = client.get("/api/auth/docusign/connect")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json()["authorization_required"] is True
|
|
assert resp.json()["authorization_url"] == "https://example.com/oauth"
|
|
assert resp.cookies.get("migrator_session") is not None
|
|
|
|
|
|
def test_docusign_callback_requires_matching_state():
|
|
"""DocuSign callback is rejected when the session state token does not match."""
|
|
cookie = create_test_session({"docusign_oauth_state": "expected-state"})
|
|
resp = client.get(
|
|
"/api/auth/docusign/callback?code=authcode123&state=wrong-state",
|
|
cookies={"migrator_session": cookie},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "invalid oauth state" in resp.json()["error"]
|
|
|
|
|
|
def test_docusign_callback_stores_per_session_tokens():
|
|
"""DocuSign callback stores refresh/access tokens in this browser session only."""
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
cookie = create_test_session({"docusign_oauth_state": "expected-state"})
|
|
with patch(
|
|
"docusign_auth.exchange_code_for_token",
|
|
return_value={"access_token": "access-123", "refresh_token": "refresh-123", "expires_in": 3600},
|
|
), patch("web.routers.auth.fetch_userinfo", new=AsyncMock(return_value=_userinfo_payload())):
|
|
resp = client.get(
|
|
"/api/auth/docusign/callback?code=authcode123&state=expected-state",
|
|
cookies={"migrator_session": cookie},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert resp.status_code in (302, 307)
|
|
session_cookie = resp.cookies.get("migrator_session")
|
|
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
|
|
assert status_resp.json()["docusign"] is True
|
|
assert status_resp.json()["docusign_auth_mode"] == "session_oauth"
|
|
assert status_resp.json()["docusign_account_selection_required"] is True
|
|
|
|
|
|
def test_docusign_sessions_are_isolated():
|
|
"""One tester's DocuSign connection does not authenticate another tester."""
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
session_a = create_test_session({"docusign_oauth_state": "state-a"})
|
|
session_b = create_test_session({})
|
|
|
|
with patch(
|
|
"docusign_auth.exchange_code_for_token",
|
|
return_value={"access_token": "access-a", "refresh_token": "refresh-a", "expires_in": 3600},
|
|
), patch("web.routers.auth.fetch_userinfo", new=AsyncMock(return_value=_userinfo_payload())):
|
|
with TestClient(app, raise_server_exceptions=True) as client_a:
|
|
callback_resp = client_a.get(
|
|
"/api/auth/docusign/callback?code=authcode123&state=state-a",
|
|
cookies={"migrator_session": session_a},
|
|
follow_redirects=False,
|
|
)
|
|
cookie_a = callback_resp.cookies.get("migrator_session")
|
|
status_a = client_a.get("/api/auth/status", cookies={"migrator_session": cookie_a})
|
|
|
|
with TestClient(app, raise_server_exceptions=True) as client_b:
|
|
status_b = client_b.get("/api/auth/status", cookies={"migrator_session": session_b})
|
|
|
|
assert status_a.json()["docusign"] is True
|
|
assert status_b.json()["docusign"] is False
|
|
|
|
|
|
def test_docusign_accounts_are_sorted_and_selectable():
|
|
"""Account picker returns alphabetically sorted accounts and stores the user's selection."""
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
cookie = create_test_session({"docusign_oauth_state": "expected-state"})
|
|
with patch(
|
|
"docusign_auth.exchange_code_for_token",
|
|
return_value={"access_token": "access-123", "refresh_token": "refresh-123", "expires_in": 3600},
|
|
), patch("web.routers.auth.fetch_userinfo", new=AsyncMock(return_value=_userinfo_payload())):
|
|
resp = client.get(
|
|
"/api/auth/docusign/callback?code=authcode123&state=expected-state",
|
|
cookies={"migrator_session": cookie},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
session_cookie = resp.cookies.get("migrator_session")
|
|
accounts_resp = client.get("/api/auth/docusign/accounts", cookies={"migrator_session": session_cookie})
|
|
assert accounts_resp.status_code == 200
|
|
accounts = accounts_resp.json()["accounts"]
|
|
assert [a["account_name"] for a in accounts] == ["Alpha Team", "Zulu Team"]
|
|
assert accounts_resp.json()["selection_required"] is True
|
|
|
|
select_resp = client.post(
|
|
"/api/auth/docusign/account-select",
|
|
json={"account_id": "aaa-account"},
|
|
cookies={"migrator_session": session_cookie},
|
|
)
|
|
assert select_resp.status_code == 200
|
|
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
|
|
assert status_resp.json()["docusign_account_id"] == "aaa-account"
|
|
assert status_resp.json()["docusign_account_name"] == "Alpha Team"
|
|
assert status_resp.json()["docusign_account_selection_required"] is False
|
|
|
|
|
|
@respx.mock
|
|
def test_disconnect_clears_token():
|
|
"""After disconnect, status shows platform as disconnected."""
|
|
respx.post(_ADOBE_TOKEN_URL).mock(
|
|
return_value=httpx.Response(200, json={"access_token": "tok", "refresh_token": "ref"})
|
|
)
|
|
|
|
# Connect Adobe via exchange
|
|
connect_resp = client.post(
|
|
"/api/auth/adobe/exchange",
|
|
json={"redirect_url": "http://localhost:8000/api/auth/adobe/callback?code=abc"},
|
|
)
|
|
session_cookie = connect_resp.cookies["migrator_session"]
|
|
|
|
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
|
|
assert status_resp.json()["adobe"] is True
|
|
|
|
disc_resp = client.get("/api/auth/adobe/disconnect", cookies={"migrator_session": session_cookie})
|
|
assert disc_resp.status_code == 200
|
|
new_cookie = disc_resp.cookies.get("migrator_session", session_cookie)
|
|
|
|
status_resp2 = client.get("/api/auth/status", cookies={"migrator_session": new_cookie})
|
|
assert status_resp2.json()["adobe"] is False
|