From 20817e4cf4b3553264997d7def6fcf90a8fe39e4 Mon Sep 17 00:00:00 2001 From: Sebb Date: Sun, 18 Jan 2026 08:52:45 +0000 Subject: [PATCH 1/6] Check method for subclass as well --- src/asfquart/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/asfquart/auth.py b/src/asfquart/auth.py index 7380cfb..fd7423b 100644 --- a/src/asfquart/auth.py +++ b/src/asfquart/auth.py @@ -76,7 +76,7 @@ def requirements_to_iter(args: typing.Any): args = [args] # Test that each requirement is an allowed one (belongs to the Requirements class) for req in args: - if not callable(req) or req != getattr(Requirements, req.__name__, None): + if not callable(req) or not issubclass(req.__self__, Requirements): raise TypeError( f"Authentication requirement {req} is not valid. Must belong to the asfquart.auth.Requirements class." ) From f49457e02ab430b3666d40acc07e2190ba4c922d Mon Sep 17 00:00:00 2001 From: Sebb Date: Sun, 18 Jan 2026 12:32:03 +0000 Subject: [PATCH 2/6] Add tests for extended auth methods --- tests/auth.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/auth.py b/tests/auth.py index 341eb4d..1dda794 100644 --- a/tests/auth.py +++ b/tests/auth.py @@ -8,6 +8,18 @@ import asfquart.auth from asfquart.auth import Requirements as R +class MyR(R): + """Test auth methods in a Requirements subclass""" + + E_ALWAYS_FALSE = "Always False" + + @classmethod + def true(cls, _session): + return True, "" + + @classmethod + def false(cls, _session): + return False, cls.E_ALWAYS_FALSE @pytest.mark.auth async def test_auth_basics(): @@ -124,3 +136,21 @@ async def test_member_or_chair_auth(): # Test for both member and chair, when we are both. should work. quart.session = {app.app_id: {"uts": time.time(), "foo": "bar", "isMember": True, "isChair": True}} await test_member_and_chair_auth() + +@pytest.mark.auth +async def test_extended_auth(): + """Extended auth tests""" + + @asfquart.auth.require(MyR.true) + async def test_true(): + pass + + @asfquart.auth.require(MyR.false) + async def test_false(): + pass + + # Should always work + await test_true() + + with pytest.raises(asfquart.auth.AuthenticationFailed, match=MyR.E_ALWAYS_FALSE): + await test_false() From f0febd210d0dbf2e723070000a57c06e4afac545 Mon Sep 17 00:00:00 2001 From: Sebb Date: Sun, 18 Jan 2026 13:06:52 +0000 Subject: [PATCH 3/6] Merge branch 'main' into sebbASF-issue41 --- tests/auth.py | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/tests/auth.py b/tests/auth.py index 1dda794..64e1c2c 100644 --- a/tests/auth.py +++ b/tests/auth.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import time +import re import pytest import quart @@ -21,6 +22,10 @@ def true(cls, _session): def false(cls, _session): return False, cls.E_ALWAYS_FALSE +def _string_to_re(s): + """convert arbitrary string to fullmatch regex""" + return re.escape(s) + '$' + @pytest.mark.auth async def test_auth_basics(): app = asfquart.construct("foobar", token_file=None) @@ -32,10 +37,8 @@ async def requires_session(): # Test with no session, should fail quart.session = {} - try: + with pytest.raises(asfquart.auth.AuthenticationFailed, match=_string_to_re(R.E_NOT_LOGGED_IN)): await requires_session() - except asfquart.auth.AuthenticationFailed as e: - assert e.message is R.E_NOT_LOGGED_IN # Test with session, should work. quart.session = {app.app_id: {"uts": time.time(), "foo": "bar"}} @@ -65,17 +68,13 @@ async def requires_mfa(): # Test MFA with no session, should fail exactly like auth_required quart.session = {} - try: + with pytest.raises(asfquart.auth.AuthenticationFailed, match=_string_to_re(R.E_NOT_LOGGED_IN)): await requires_mfa() - except asfquart.auth.AuthenticationFailed as e: - assert e.message is R.E_NOT_LOGGED_IN # Test with session without MFA, should fail. quart.session = {app.app_id: {"uts": time.time(), "foo": "bar"}} - try: + with pytest.raises(asfquart.auth.AuthenticationFailed, match=_string_to_re(R.E_NO_MFA)): await requires_mfa() - except asfquart.auth.AuthenticationFailed as e: - assert e.message is R.E_NO_MFA # Test with session with MFA, should work. quart.session = {app.app_id: {"uts": time.time(), "foo": "bar", "mfa": True}} @@ -107,27 +106,21 @@ async def test_member_or_chair_auth(): # Test role with no session, should fail exactly like auth_required quart.session = {} - try: + with pytest.raises(asfquart.auth.AuthenticationFailed, match=_string_to_re(R.E_NOT_LOGGED_IN)): await test_committer_auth() - except asfquart.auth.AuthenticationFailed as e: - assert e.message is R.E_NOT_LOGGED_IN # Test with session , should work quart.session = {app.app_id: {"uts": time.time(), "foo": "bar"}} await test_committer_auth() # Test with a role we don't have, should fail - try: + with pytest.raises(asfquart.auth.AuthenticationFailed, match=_string_to_re(R.E_NOT_MEMBER)): await test_member_auth() - except asfquart.auth.AuthenticationFailed as e: - assert e.message is R.E_NOT_MEMBER # Test with for both member and chair, while only being member. should pass on member check, fail on chair quart.session = {app.app_id: {"uts": time.time(), "foo": "bar", "isMember": True}} - try: + with pytest.raises(asfquart.auth.AuthenticationFailed, match=_string_to_re(R.E_NOT_CHAIR)): await test_member_and_chair_auth() - except asfquart.auth.AuthenticationFailed as e: - assert e.message is R.E_NOT_CHAIR # Test for either member of chair, should work as we have chair (but not member) quart.session = {app.app_id: {"uts": time.time(), "foo": "bar", "isChair": True}} From aafce652fb2f7b01b581a08967c2ebd91723b405 Mon Sep 17 00:00:00 2001 From: Sebb Date: Sun, 18 Jan 2026 13:08:14 +0000 Subject: [PATCH 4/6] Ensure fixed string match --- tests/auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/auth.py b/tests/auth.py index 64e1c2c..495ecff 100644 --- a/tests/auth.py +++ b/tests/auth.py @@ -144,6 +144,6 @@ async def test_false(): # Should always work await test_true() - - with pytest.raises(asfquart.auth.AuthenticationFailed, match=MyR.E_ALWAYS_FALSE): + + with pytest.raises(asfquart.auth.AuthenticationFailed, match=_string_to_re(MyR.E_ALWAYS_FALSE)): await test_false() From 2c2482989c6641e3fb68e25b27535ac13b68afe0 Mon Sep 17 00:00:00 2001 From: Sebb Date: Mon, 19 Jan 2026 15:33:17 +0000 Subject: [PATCH 5/6] Add tests with independent class --- tests/auth.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/auth.py b/tests/auth.py index 495ecff..021cda7 100644 --- a/tests/auth.py +++ b/tests/auth.py @@ -22,6 +22,19 @@ def true(cls, _session): def false(cls, _session): return False, cls.E_ALWAYS_FALSE +class LoneR(): + """Test auth methods in an independent class""" + + E_ALWAYS_FALSE = "Always False" + + @classmethod + def true(cls, _session): + return True, "" + + @classmethod + def false(cls, _session): + return False, cls.E_ALWAYS_FALSE + def _string_to_re(s): """convert arbitrary string to fullmatch regex""" return re.escape(s) + '$' @@ -147,3 +160,18 @@ async def test_false(): with pytest.raises(asfquart.auth.AuthenticationFailed, match=_string_to_re(MyR.E_ALWAYS_FALSE)): await test_false() + +@pytest.mark.auth +async def test_lone_auth(): + """Extended auth tests using independent class""" + + # cannot use independent class as a decorator + with pytest.raises(TypeError): + @asfquart.auth.require(LoneR.true) + async def test_true(): + pass + + with pytest.raises(TypeError): + @asfquart.auth.require(LoneR.false) + async def test_false(): + pass From e15be841d8292f5a9dd0e7de899fecaeec566fe1 Mon Sep 17 00:00:00 2001 From: Sebb Date: Wed, 21 Jan 2026 09:34:48 +0000 Subject: [PATCH 6/6] Ensure tests actually generate expected failures (#47) --- tests/auth.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/auth.py b/tests/auth.py index 021cda7..9d6e9c3 100644 --- a/tests/auth.py +++ b/tests/auth.py @@ -39,6 +39,10 @@ def _string_to_re(s): """convert arbitrary string to fullmatch regex""" return re.escape(s) + '$' +def _string_to_re(s): + """convert arbitrary string to fullmatch regex""" + return re.escape(s) + '$' + @pytest.mark.auth async def test_auth_basics(): app = asfquart.construct("foobar", token_file=None)