""" 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")) 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"] # Must use the registered redirect URI assert "localhost%3A8080" in data["url"] or "localhost:8080" in data["url"] 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"): 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 def test_adobe_connect_env_fails_without_credentials(monkeypatch): """GET /api/auth/adobe/connect with no .env tokens → 400.""" 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 == 400 assert "No Adobe Sign credentials" in resp.json()["error"] @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 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": "https://localhost:8080/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