Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
122 changes: 122 additions & 0 deletions tests/test_auth/test_membersuite_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""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