-
Notifications
You must be signed in to change notification settings - Fork 0
Skip membership check for IRE staff #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
6c90f68
Initial plan
Copilot 9b14b14
Skip membership check for IRE staff (@ire.org email addresses)
Copilot 97b9fd6
Update app/auth/dependencies.py
palewire f7a3043
Apply @ire.org bypass at authentication time in authenticate_and_verify
Copilot ea6d4c5
Fix ruff formatting in test_membersuite_client.py
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.