9c3d3ef7ed
- cantaloupe/ package with click CLI (python -m cantaloupe export) - export command: --output, --headless/--visible, --scheduled, --email, --password - configure command: saves credentials to ~/.cantaloupe.env (600 perms) - cantaloupe/config.py: credential loading from env vars + env file - Updated src/auth.py _get_credentials() to also check ~/.cantaloupe.env - File size, row count (openpyxl), and elapsed time reporting - 39 new CLI/config tests + 4 updated auth tests; 86/86 pass
363 lines
13 KiB
Python
363 lines
13 KiB
Python
"""Tests for auth module — unit tests with mocked httpx via respx."""
|
|
import os
|
|
from unittest.mock import patch
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
|
|
from src.auth import (
|
|
ANTI_FORGERY_API,
|
|
AUTH_COOKIE_NAMES,
|
|
SIGNIN_API,
|
|
AuthSession,
|
|
AuthenticationError,
|
|
_do_login,
|
|
_get_anti_forgery_state,
|
|
_get_credentials,
|
|
get_session,
|
|
login,
|
|
)
|
|
|
|
|
|
# ─────────────────────────────────────────────────
|
|
# _get_credentials
|
|
# ─────────────────────────────────────────────────
|
|
|
|
|
|
def test_get_credentials_from_env():
|
|
with patch.dict(os.environ, {"CANTALOUPE_EMAIL": "u@test.com", "CANTALOUPE_PASSWORD": "pw"}):
|
|
email, password = _get_credentials()
|
|
assert email == "u@test.com"
|
|
assert password == "pw"
|
|
|
|
|
|
def test_get_credentials_missing_email():
|
|
with patch.dict(os.environ, {"CANTALOUPE_PASSWORD": "pw"}, clear=True):
|
|
with patch("src.auth.os.path.expanduser", return_value="/nonexistent/.cantaloupe.env"):
|
|
with pytest.raises(AuthenticationError, match="Credentials not found"):
|
|
_get_credentials()
|
|
|
|
|
|
def test_get_credentials_missing_password():
|
|
with patch.dict(os.environ, {"CANTALOUPE_EMAIL": "u@test.com"}, clear=True):
|
|
with patch("src.auth.os.path.expanduser", return_value="/nonexistent/.cantaloupe.env"):
|
|
with pytest.raises(AuthenticationError, match="Credentials not found"):
|
|
_get_credentials()
|
|
|
|
|
|
def test_get_credentials_none_set():
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
with patch("src.auth.os.path.expanduser", return_value="/nonexistent/.cantaloupe.env"):
|
|
with pytest.raises(AuthenticationError, match="Credentials not found"):
|
|
_get_credentials()
|
|
|
|
|
|
# ─────────────────────────────────────────────────
|
|
# _get_anti_forgery_state
|
|
# ─────────────────────────────────────────────────
|
|
|
|
|
|
@respx.mock
|
|
def test_anti_forgery_success():
|
|
token = "CfDJ8ExampleToken12345"
|
|
respx.get(ANTI_FORGERY_API).respond(
|
|
json={"HeaderName": "__RequestVerificationToken", "Token": token}
|
|
)
|
|
client = httpx.Client(base_url="https://mycantaloupe.com")
|
|
result = _get_anti_forgery_state(client)
|
|
assert result == token
|
|
|
|
|
|
@respx.mock
|
|
def test_anti_forgery_empty_token():
|
|
respx.get(ANTI_FORGERY_API).respond(
|
|
json={"HeaderName": "__RequestVerificationToken", "Token": ""}
|
|
)
|
|
client = httpx.Client(base_url="https://mycantaloupe.com")
|
|
with pytest.raises(AuthenticationError, match="Failed to obtain anti-forgery"):
|
|
_get_anti_forgery_state(client)
|
|
|
|
|
|
@respx.mock
|
|
def test_anti_forgery_missing_token_key():
|
|
respx.get(ANTI_FORGERY_API).respond(json={"other": "data"})
|
|
client = httpx.Client(base_url="https://mycantaloupe.com")
|
|
with pytest.raises(AuthenticationError, match="Failed to obtain anti-forgery"):
|
|
_get_anti_forgery_state(client)
|
|
|
|
|
|
@respx.mock
|
|
def test_anti_forgery_non_200():
|
|
respx.get(ANTI_FORGERY_API).respond(status_code=500)
|
|
client = httpx.Client(base_url="https://mycantaloupe.com")
|
|
with pytest.raises(httpx.HTTPStatusError):
|
|
_get_anti_forgery_state(client)
|
|
|
|
|
|
# ─────────────────────────────────────────────────
|
|
# _do_login
|
|
# ─────────────────────────────────────────────────
|
|
|
|
|
|
@respx.mock
|
|
def test_do_login_success_with_result():
|
|
respx.post(SIGNIN_API).respond(json={"SuccessfulResult": {"UserId": 42}})
|
|
client = httpx.Client(base_url="https://mycantaloupe.com")
|
|
result = _do_login(client, "u@test.com", "pw", "csrf-token")
|
|
assert result["SuccessfulResult"]["UserId"] == 42
|
|
|
|
|
|
@respx.mock
|
|
def test_do_login_success_with_redirect():
|
|
respx.post(SIGNIN_API).respond(json={"RedirectUrl": "/cs4/VueMachineList"})
|
|
client = httpx.Client(base_url="https://mycantaloupe.com")
|
|
result = _do_login(client, "u@test.com", "pw", "csrf-token")
|
|
assert result["RedirectUrl"] == "/cs4/VueMachineList"
|
|
|
|
|
|
@respx.mock
|
|
def test_do_login_error_message():
|
|
respx.post(SIGNIN_API).respond(
|
|
json={"ErrorMessage": "Incorrect username or password"}
|
|
)
|
|
client = httpx.Client(base_url="https://mycantaloupe.com")
|
|
with pytest.raises(AuthenticationError, match="Login rejected: Incorrect"):
|
|
_do_login(client, "bad@test.com", "wrong", "csrf-token")
|
|
|
|
|
|
@respx.mock
|
|
def test_do_login_not_verified():
|
|
respx.post(SIGNIN_API).respond(
|
|
json={"NotVerifiedUserEmail": "u@test.com"}
|
|
)
|
|
client = httpx.Client(base_url="https://mycantaloupe.com")
|
|
with pytest.raises(AuthenticationError, match="Email not verified"):
|
|
_do_login(client, "u@test.com", "pw", "csrf-token")
|
|
|
|
|
|
@respx.mock
|
|
def test_do_login_non_200():
|
|
respx.post(SIGNIN_API).respond(status_code=500, text="Internal Server Error")
|
|
client = httpx.Client(base_url="https://mycantaloupe.com")
|
|
with pytest.raises(AuthenticationError, match="failed with status 500"):
|
|
_do_login(client, "u@test.com", "pw", "csrf-token")
|
|
|
|
|
|
@respx.mock
|
|
def test_do_login_sends_correct_headers():
|
|
route = respx.post(SIGNIN_API).respond(json={"SuccessfulResult": {}})
|
|
client = httpx.Client(base_url="https://mycantaloupe.com")
|
|
_do_login(client, "u@test.com", "pw", "csrf-token-abc")
|
|
req = route.calls.last.request
|
|
assert req.headers["__RequestVerificationToken"] == "csrf-token-abc"
|
|
assert req.headers["Content-Type"] == "application/json"
|
|
body = req.content.decode()
|
|
assert "u@test.com" in body
|
|
assert '"pw"' in body
|
|
assert "FormsAuthReturnUrl" in body
|
|
|
|
|
|
@respx.mock
|
|
def test_do_login_unclear_response_still_returns():
|
|
respx.post(SIGNIN_API).respond(json={"SomeField": "value"})
|
|
client = httpx.Client(base_url="https://mycantaloupe.com")
|
|
result = _do_login(client, "u@test.com", "pw", "csrf-token")
|
|
assert result == {"SomeField": "value"}
|
|
|
|
|
|
# ─────────────────────────────────────────────────
|
|
# login() — full flow
|
|
# ─────────────────────────────────────────────────
|
|
|
|
|
|
@respx.mock
|
|
def test_login_full_flow():
|
|
csrf_token = "CsrfToken123"
|
|
session_cookie = "abc123session"
|
|
auth_cookie = "authcookie456"
|
|
|
|
respx.get(ANTI_FORGERY_API).respond(
|
|
json={"HeaderName": "__RequestVerificationToken", "Token": csrf_token}
|
|
)
|
|
login_route = respx.post(SIGNIN_API).respond(
|
|
json={"SuccessfulResult": {"UserId": 1}},
|
|
headers=[
|
|
("Set-Cookie", f"ASP.NET_SessionId={session_cookie}; Path=/; HttpOnly; SameSite=Lax"),
|
|
("Set-Cookie", f"ApplicationGatewayAffinity={auth_cookie}; Path=/"),
|
|
],
|
|
)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{"CANTALOUPE_EMAIL": "u@test.com", "CANTALOUPE_PASSWORD": "pw"},
|
|
):
|
|
auth = login()
|
|
|
|
assert auth.email == "u@test.com"
|
|
assert auth.password == "pw"
|
|
assert "ASP.NET_SessionId" in auth.cookies
|
|
assert "ApplicationGatewayAffinity" in auth.cookies
|
|
assert auth.client.cookies["ApplicationGatewayAffinity"] == auth_cookie
|
|
|
|
csrf_call = respx.get(ANTI_FORGERY_API).calls.last
|
|
assert csrf_call is not None
|
|
login_call = login_route.calls.last
|
|
assert login_call.request.headers["__RequestVerificationToken"] == csrf_token
|
|
|
|
|
|
@respx.mock
|
|
def test_login_with_explicit_credentials():
|
|
csrf_token = "token"
|
|
respx.get(ANTI_FORGERY_API).respond(
|
|
json={"HeaderName": "__RequestVerificationToken", "Token": csrf_token}
|
|
)
|
|
respx.post(SIGNIN_API).respond(json={"SuccessfulResult": {}})
|
|
|
|
auth = login(email="explicit@test.com", password="explicitpw")
|
|
assert auth.email == "explicit@test.com"
|
|
assert auth.password == "explicitpw"
|
|
|
|
|
|
@respx.mock
|
|
def test_login_with_custom_client():
|
|
respx.get(ANTI_FORGERY_API).respond(
|
|
json={"HeaderName": "__RequestVerificationToken", "Token": "t"}
|
|
)
|
|
respx.post(SIGNIN_API).respond(json={"SuccessfulResult": {}})
|
|
|
|
custom_client = httpx.Client(base_url="https://mycantaloupe.com", timeout=30.0)
|
|
auth = login(email="u@test.com", password="pw", client=custom_client)
|
|
assert auth.client is custom_client
|
|
|
|
|
|
def test_login_missing_credentials():
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
with patch("src.auth.os.path.expanduser", return_value="/nonexistent/.cantaloupe.env"):
|
|
with pytest.raises(AuthenticationError, match="Credentials not found"):
|
|
login()
|
|
|
|
|
|
# ─────────────────────────────────────────────────
|
|
# get_session() — convenience factory
|
|
# ─────────────────────────────────────────────────
|
|
|
|
|
|
@respx.mock
|
|
def test_get_session_force_login():
|
|
respx.get(ANTI_FORGERY_API).respond(
|
|
json={"HeaderName": "__RequestVerificationToken", "Token": "t"}
|
|
)
|
|
respx.post(SIGNIN_API).respond(json={"SuccessfulResult": {}})
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{"CANTALOUPE_EMAIL": "u@test.com", "CANTALOUPE_PASSWORD": "pw"},
|
|
):
|
|
client = get_session(force_login=True)
|
|
|
|
assert isinstance(client, httpx.Client)
|
|
assert respx.get(ANTI_FORGERY_API).called
|
|
assert respx.post(SIGNIN_API).called
|
|
|
|
|
|
@respx.mock
|
|
def test_get_session_cached():
|
|
respx.get("https://mycantaloupe.com/cs4/VueMachineList").respond(
|
|
status_code=200,
|
|
headers={
|
|
"Set-Cookie": "ApplicationGatewayAffinity=cached-cookie; Path=/"
|
|
},
|
|
)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{"CANTALOUPE_EMAIL": "u@test.com", "CANTALOUPE_PASSWORD": "pw"},
|
|
):
|
|
client = get_session()
|
|
|
|
assert isinstance(client, httpx.Client)
|
|
assert client.cookies.get("ApplicationGatewayAffinity") == "cached-cookie"
|
|
assert not respx.get(ANTI_FORGERY_API).called
|
|
assert not respx.post(SIGNIN_API).called
|
|
|
|
|
|
@respx.mock
|
|
def test_get_session_probe_fails_triggers_login():
|
|
respx.get("https://mycantaloupe.com/cs4/VueMachineList").respond(status_code=200)
|
|
respx.get(ANTI_FORGERY_API).respond(
|
|
json={"HeaderName": "__RequestVerificationToken", "Token": "t"}
|
|
)
|
|
respx.post(SIGNIN_API).respond(json={"SuccessfulResult": {}})
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{"CANTALOUPE_EMAIL": "u@test.com", "CANTALOUPE_PASSWORD": "pw"},
|
|
):
|
|
client = get_session()
|
|
|
|
assert isinstance(client, httpx.Client)
|
|
assert respx.post(SIGNIN_API).called
|
|
|
|
|
|
# ─────────────────────────────────────────────────
|
|
# AuthSession.refresh()
|
|
# ─────────────────────────────────────────────────
|
|
|
|
|
|
@respx.mock
|
|
def test_auth_session_refresh_cookies_present():
|
|
client = httpx.Client(base_url="https://mycantaloupe.com")
|
|
client.cookies.set("ApplicationGatewayAffinity", "still-here", domain="mycantaloupe.com")
|
|
session = AuthSession(client=client, email="u@test.com", password="pw")
|
|
assert session.refresh() is True
|
|
|
|
|
|
@respx.mock
|
|
def test_auth_session_refresh_relogin_success():
|
|
client = httpx.Client(base_url="https://mycantaloupe.com")
|
|
session = AuthSession(client=client, email="u@test.com", password="pw")
|
|
|
|
respx.get(ANTI_FORGERY_API).respond(
|
|
json={"HeaderName": "__RequestVerificationToken", "Token": "new-token"}
|
|
)
|
|
respx.post(SIGNIN_API).respond(
|
|
json={"SuccessfulResult": {}},
|
|
headers={
|
|
"Set-Cookie": "ApplicationGatewayAffinity=new-auth; Path=/"
|
|
},
|
|
)
|
|
|
|
assert session.refresh() is True
|
|
assert session.client.cookies.get("ApplicationGatewayAffinity") == "new-auth"
|
|
|
|
|
|
@respx.mock
|
|
def test_auth_session_refresh_relogin_fails():
|
|
client = httpx.Client(base_url="https://mycantaloupe.com")
|
|
session = AuthSession(client=client, email="u@test.com", password="bad")
|
|
|
|
respx.get(ANTI_FORGERY_API).respond(
|
|
json={"HeaderName": "__RequestVerificationToken", "Token": "t"}
|
|
)
|
|
respx.post(SIGNIN_API).respond(
|
|
json={"ErrorMessage": "Invalid credentials"}
|
|
)
|
|
|
|
assert session.refresh() is False
|
|
|
|
|
|
# ─────────────────────────────────────────────────
|
|
# Constants
|
|
# ─────────────────────────────────────────────────
|
|
|
|
|
|
def test_auth_cookie_names():
|
|
assert "ApplicationGatewayAffinity" in AUTH_COOKIE_NAMES
|
|
assert "AeonPrincipalCacheVersion" in AUTH_COOKIE_NAMES
|
|
|
|
|
|
def test_url_constants():
|
|
assert SIGNIN_API == "https://mycantaloupe.com/login/api/SignIn/SignIn/en-US"
|
|
assert ANTI_FORGERY_API == "https://mycantaloupe.com/login/api/DataContext/GenerateAntiForgeryState"
|