Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/auth/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,13 @@ async def require_member(
) -> Session:
"""Require session with active membership.

IRE staff (email addresses ending with @ire.org) are granted access
regardless of membership status.

Use for member-only endpoints.
"""
if not session.is_active_member:
is_ire_staff = session.email.lower().endswith("@ire.org")
if not is_ire_staff and not session.is_active_member:
from app.auth.exceptions import MembershipRequiredError

raise MembershipRequiredError()
Comment thread
palewire marked this conversation as resolved.
Expand Down
4 changes: 3 additions & 1 deletion app/auth/membersuite_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,9 @@ async def authenticate_and_verify(
user = await self.get_user_info(auth_token)

# Step 3: Verify membership if required
if require_membership and not user.is_active_member:
# IRE staff (email addresses ending with @ire.org) bypass the membership check.
is_ire_staff = user.email.lower().endswith("@ire.org")
if require_membership and not is_ire_staff and not user.is_active_member:
logger.warning(
"membersuite_membership_denied",
user_id=user.user_id,
Expand Down
114 changes: 114 additions & 0 deletions tests/test_auth/test_membersuite_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Tests for MemberSuiteClient.authenticate_and_verify membership bypass."""

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from app.auth.exceptions import MembershipRequiredError
from app.auth.membersuite_client import MemberSuiteClient, MemberSuiteUser


def _make_user(email: str, receives_member_benefits: bool | None) -> MemberSuiteUser:
return MemberSuiteUser(
tenant_id=1,
association_id="assoc-1",
user_id="user-1",
email=email,
first_name="Test",
last_name="User",
owner_id="owner-1",
membership_id=None,
receives_member_benefits=receives_member_benefits,
username="testuser",
)


@pytest.fixture
def client(monkeypatch):
"""MemberSuiteClient with mocked HTTP and token exchange."""
import httpx
from app.auth.config import AuthSettings

settings = AuthSettings(
tenant_id="test-tenant",
association_id="test-assoc",
redis_url="redis://localhost",
session_secret="x" * 32,
frontend_url="http://localhost:5173",
)
http_client = MagicMock(spec=httpx.AsyncClient)
return MemberSuiteClient(settings=settings, http_client=http_client)


class TestAuthenticateAndVerifyMembershipBypass:
@pytest.mark.asyncio
async def test_active_member_allowed(self, client):
"""Active members pass when require_membership=True."""
user = _make_user("journalist@example.com", receives_member_benefits=True)
client.exchange_token_guid = AsyncMock(return_value="token-abc")
client.get_user_info = AsyncMock(return_value=user)

auth_token, returned_user = await client.authenticate_and_verify("guid-123", require_membership=True)
assert auth_token == "token-abc"
assert returned_user is user

@pytest.mark.asyncio
async def test_inactive_member_denied_when_required(self, client):
"""Non-members are denied when require_membership=True."""
user = _make_user("journalist@example.com", receives_member_benefits=False)
client.exchange_token_guid = AsyncMock(return_value="token-abc")
client.get_user_info = AsyncMock(return_value=user)

with pytest.raises(MembershipRequiredError):
await client.authenticate_and_verify("guid-123", require_membership=True)

@pytest.mark.asyncio
async def test_inactive_member_allowed_when_not_required(self, client):
"""Non-members are allowed when require_membership=False."""
user = _make_user("journalist@example.com", receives_member_benefits=False)
client.exchange_token_guid = AsyncMock(return_value="token-abc")
client.get_user_info = AsyncMock(return_value=user)

auth_token, returned_user = await client.authenticate_and_verify("guid-123", require_membership=False)
assert auth_token == "token-abc"

@pytest.mark.asyncio
async def test_ire_staff_bypasses_membership_check(self, client):
"""IRE staff (@ire.org) bypass the membership check even when require_membership=True."""
user = _make_user("staff@ire.org", receives_member_benefits=False)
client.exchange_token_guid = AsyncMock(return_value="token-abc")
client.get_user_info = AsyncMock(return_value=user)

auth_token, returned_user = await client.authenticate_and_verify("guid-123", require_membership=True)
assert auth_token == "token-abc"
assert returned_user is user

@pytest.mark.asyncio
async def test_ire_staff_no_membership_case_insensitive(self, client):
"""IRE staff email check is case-insensitive."""
user = _make_user("STAFF@IRE.ORG", receives_member_benefits=None)
client.exchange_token_guid = AsyncMock(return_value="token-abc")
client.get_user_info = AsyncMock(return_value=user)

auth_token, returned_user = await client.authenticate_and_verify("guid-123", require_membership=True)
assert auth_token == "token-abc"

@pytest.mark.asyncio
async def test_non_ire_domain_not_bypassed(self, client):
"""Emails at similar-but-different domains are still subject to membership check."""
user = _make_user("user@notire.org", receives_member_benefits=False)
client.exchange_token_guid = AsyncMock(return_value="token-abc")
client.get_user_info = AsyncMock(return_value=user)

with pytest.raises(MembershipRequiredError):
await client.authenticate_and_verify("guid-123", require_membership=True)

@pytest.mark.asyncio
async def test_ire_subdomain_not_bypassed(self, client):
"""Subdomains of ire.org are not treated as IRE staff."""
user = _make_user("user@staff.ire.org", receives_member_benefits=False)
client.exchange_token_guid = AsyncMock(return_value="token-abc")
client.get_user_info = AsyncMock(return_value=user)

with pytest.raises(MembershipRequiredError):
await client.authenticate_and_verify("guid-123", require_membership=True)
75 changes: 75 additions & 0 deletions tests/test_auth/test_require_member.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Tests for the require_member dependency."""

import time

import pytest

from app.auth.dependencies import require_member
from app.auth.exceptions import MembershipRequiredError
from app.auth.session import Session


def _make_session(email: str, is_active_member: bool) -> Session:
return Session(
session_id="test-session-id",
user_id="test-user-123",
email=email,
first_name="Test",
last_name="User",
full_name="Test User",
is_active_member=is_active_member,
membership_id=None,
created_at=time.time(),
expires_at=9999999999,
)


class TestRequireMember:
@pytest.mark.asyncio
async def test_active_member_allowed(self):
"""Regular active members are allowed access."""
session = _make_session("journalist@example.com", is_active_member=True)
result = await require_member(session)
assert result is session

@pytest.mark.asyncio
async def test_inactive_member_denied(self):
"""Non-members without an ire.org email are denied."""
session = _make_session("journalist@example.com", is_active_member=False)
with pytest.raises(MembershipRequiredError):
await require_member(session)

@pytest.mark.asyncio
async def test_ire_staff_allowed_without_membership(self):
"""IRE staff (ire.org email) bypass the membership check."""
session = _make_session("staff@ire.org", is_active_member=False)
result = await require_member(session)
assert result is session

@pytest.mark.asyncio
async def test_ire_staff_allowed_with_membership(self):
"""IRE staff with active membership are also allowed."""
session = _make_session("staff@ire.org", is_active_member=True)
result = await require_member(session)
assert result is session

@pytest.mark.asyncio
async def test_ire_staff_email_case_insensitive(self):
"""Email domain check is case-insensitive."""
session = _make_session("STAFF@IRE.ORG", is_active_member=False)
result = await require_member(session)
assert result is session

@pytest.mark.asyncio
async def test_non_ire_domain_denied(self):
"""Emails that merely contain ire.org but are not @ire.org are denied."""
session = _make_session("attacker@notire.org", is_active_member=False)
with pytest.raises(MembershipRequiredError):
await require_member(session)

@pytest.mark.asyncio
async def test_ire_org_subdomain_denied(self):
"""Subdomains of ire.org (e.g. staff.ire.org) are denied — only @ire.org is allowed."""
session = _make_session("user@staff.ire.org", is_active_member=False)
with pytest.raises(MembershipRequiredError):
await require_member(session)
Loading