Skip to content
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

Improve add-member API functests #9110

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

seanh
Copy link
Contributor

@seanh seanh commented Nov 22, 2024

The functional tests for the add-member API are a mess: they're in kind
of an old-fashioned coding style, but more importantly there are some
missing test cases and there are several tests that aren't actually
testing what they're supposed to be testing because the test setup is
wrong, and at least one of these tests was passing when there was
actually a crash in the code.

A difficulty is that the h API doesn't return specific error responses.
Whenever anything goes wrong with an add-member API request h returns a
404 with the same fixed body that doesn't say specifically what was
wrong with the request. This means that tests can't assert that they've
triggered the error that they intended to trigger. They can only assert
that the response is a 404. Several of add-member's functests were
actually triggering a different 404 than what the test was intending
to trigger because the test setup was wrong, but the tests were still
passing.

For example this test doesn't actually test what it's intending to test:

def test_it_returns_404_if_authority_mismatch_on_user(
    self, app, factories, auth_client
):
    group = factories.Group()
    user = factories.User(authority="somewhere-else.org")

    res = app.post_json(
        "/api/groups/{pubid}/members/{userid}".format(
            pubid=group.pubid, userid=user.userid
        ),
        headers=self.authorization_header(auth_client),
        expect_errors=True,
    )

    assert res.status_code == 404

The group that the test has created has an authority that doesn't
match that of the auth_client so the request will 404 for that reason
before the code ever gets to the user's authority. In addition the
db_session hasn't been commited so neither group or user have been
written to the DB, which will also cause the request to 404. The test
needs to create a group with the right authority, a user with the wrong
authority, and add both to the DB.

In this commit I've adopted an approach where class-level test fixtures
set up everything that's needed for the API request to succeed. The
test for the happy-path doesn't need to do any setup beyond what the
fixtures have already done. Tests for error cases then change one
particular thing before calling the API. This means the test can be sure
that the 404 was triggered by the one thing it changed rather than by
anything else. For example:

class TestAddMember:
    def test_it(self, do_request, group, user):
        do_request()

        assert user in group.members

    def test_it_errors_if_the_request_isnt_authenticated(self, do_request, headers):
        del headers["Authorization"]

        do_request(status=404)

    ...

    @pytest.fixture
    def authclient(self):
        """Return a group with an authority matching authclient's."""

    @pytest.fixture
    def headers(self, authclient):
        """Return an `Authorization` header for authclient."""

    @pytest.fixture
    def group(self, authclient):
        """Return a group with an authority matching authclient's."""

    @pytest.fixture
    def user(self, authclient):
        """Return a user with an authority matching authclient's."""

    @pytest.fixture
    def do_request(self, db_session, group, user, headers):
        def do_request(...):
            """Commit the db_session then make an API request with group, user and headers."""
        return do_request

The happy path test (test_it()) verifies that, with everything as set
up by the test fixtures, the API request succeeds and adds the user to
the group.

The error test (test_it_errors_if_the_request_isnt_authenticated())
changes one thing about the fixtures set up (it deletes the
Authorization header) and asserts that the request 404s.

This approach makes mistakes in tests much less likely.

I have also gone through breaking specific parts of the context,
security, view and service code and testing that the expected test
fails. I did this for every functest in the class.

This commit also fixes one bug in the code: if the add-member API is called
with an invalid userid (one that isn't in acct:{username}@{authority}
format) the app crashes. The previous functests actually had a test for
this case, but the test was wrong:

def test_it_returns_404_if_malformed_userid(
    self, app, factories, auth_client, db_session
):
    group = factories.Group()
    db_session.commit()

    res = app.post_json(
        "/api/groups/{pubid}/members/{userid}".format(
            pubid=group.pubid, userid="[email protected]"
        ),
        headers=self.authorization_header(auth_client),
        expect_errors=True,
    )

    assert res.status_code == 404

This test creates a group whose authority doesn't match that of the
auth_client and the request 404s for that reason before the code ever
gets to parsing the malformed userid. So the test passes. In fact if the
test had set up the group's authority correctly the app would crash on
the malformed userid. This commit replaces this test with a correct one.

@seanh seanh requested a review from marcospri November 22, 2024 12:57

assert user in group.members

def test_it_does_nothing_if_the_user_is_already_a_member_of_the_group(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This replaces a previous test that was called test_it_is_idempotent(). I think this is a better test


def test_it_returns_404_if_authority_mismatch_on_user(
self, app, factories, auth_client
def test_it_errors_if_the_groups_and_users_authorities_both_dont_match(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a dangerous case that was missing from the previous tests: the view code contains a test that the user's and group's authorities match. But what if the user and group's authorities are the same but they both differ from that of the authclient? This could allow authclients to add members to groups outside of the authclient's authority! It turns out there's no bug here because the ADD_MEMBER permission requires the group's authority to match the authclient's. But still I'm glad to have a functest for this case.

Comment on lines +138 to +139
@pytest.mark.xfail
def test_me_alias_with_forwarded_user(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A future PR is going to incidentally add support for the "me" alias to the add-member API at which time this test will pass. For now I added a failing test

def test_it_returns_404_if_missing_auth(self, app, user, db_session, factories):
group = factories.Group()
db_session.commit()
def test_me_alias_with_forwarded_user_with_wrong_authority(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to be sure

Comment on lines +162 to +168
@pytest.fixture
def do_request(self, db_session, app, group, user, headers):
def do_request(pubid=group.pubid, userid=user.userid, status=204):
db_session.commit()
return app.post_json(
f"/api/groups/{pubid}/members/{userid}", headers=headers, status=status
)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this "function as fixture" pattern is a good one: reduces duplication and really helps to avoid mistakes in test code. do_request() will make a successful request to the API unless the test has changed something about the fixtures or passes a non-default value for one of do_request()'s arguments.

It's very easy for error-case tests in this class to forget to commit the DB session and the test can end up passing even though it's not actually testing what it thinks it is. Multiple tests in the class had that problem. Having do_request() commit the DB session makes this mistake impossible.

third_party_group = factories.Group(authority="thirdparty.com")
headers = self.authorization_header(auth_client)
user2 = factories.User(authority="thirdparty.com")
db_session.commit()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test was completely wrong. It uses third_party_user.userid in both the URL and the X-Forwarded-User header and does not include user2 in the request at all. And then asserts that user2 was not added to the group. It's been replaced with a correct test.

):
group = factories.Group()
user = factories.User(authority="somewhere-else.org")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

group has wrong authority and DB session isn't committed, so test is not testing what it intends to.

):
group = factories.Group()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

group has wrong authority so test is not testing what it intends to.

):
group = factories.Group(authority="somewhere-else.org")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

user has wrong authority so test is not testing what it intends to.


def test_it_returns_404_if_missing_auth(self, app, user, db_session, factories):
group = factories.Group()
Copy link
Contributor Author

@seanh seanh Nov 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

group and user have wrong authority so test is not testing what it intends to.

def test_it_returns_404_with_token_auth(
self, app, token_auth_header, user, db_session, factories
):
group = factories.Group()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

group and user have wrong authority so test is not testing what it intends to.

@seanh seanh force-pushed the improve-add-member-api-functests branch from 55ea33e to 0a7016c Compare November 22, 2024 13:18
The functional tests for the add-member API are a mess: they're in kind
of an old-fashioned coding style, but more importantly there are some
missing test cases and there are several tests that aren't actually
testing what they're supposed to be testing because the test setup is
wrong, and at least one of these tests was passing when there was
actually a crash in the code.

A difficulty is that the h API doesn't return specific error responses.
Whenever anything goes wrong with an add-member API request h returns a
404 with the same fixed body that doesn't say specifically *what* was
wrong with the request. This means that tests can't assert that they've
triggered the error that they intended to trigger. They can only assert
that the response is a 404. Several of add-member's functests were
actually triggering a *different* 404 than what the test was intending
to trigger because the test setup was wrong, but the tests were still
passing.

For example this test doesn't actually test what it's intending to test:

    def test_it_returns_404_if_authority_mismatch_on_user(
        self, app, factories, auth_client
    ):
        group = factories.Group()
        user = factories.User(authority="somewhere-else.org")

        res = app.post_json(
            "/api/groups/{pubid}/members/{userid}".format(
                pubid=group.pubid, userid=user.userid
            ),
            headers=self.authorization_header(auth_client),
            expect_errors=True,
        )

        assert res.status_code == 404

The `group` that the test has created has an authority that doesn't
match that of the `auth_client` so the request will 404 for that reason
before the code ever gets to the `user`'s authority. In addition the
`db_session` hasn't been commited so neither `group` or `user` have been
written to the DB, which will also cause the request to 404. The test
needs to create a group with the right authority, a user with the wrong
authority, and add both to the DB.

In this commit I've adopted an approach where class-level test fixtures
set up everything that's needed for the API request to succeed.  The
test for the happy-path doesn't need to do any setup beyond what the
fixtures have already done.  Tests for error cases then change one
particular thing before calling the API. This means the test can be sure
that the 404 was triggered by the one thing it changed rather than by
anything else. For example:

    class TestAddMember:
        def test_it(self, do_request, group, user):
            do_request()

            assert user in group.members

        def test_it_errors_if_the_request_isnt_authenticated(self, do_request, headers):
            del headers["Authorization"]

            do_request(status=404)

        ...

        @pytest.fixture
        def authclient(self):
            """Return a group with an authority matching authclient's."""

        @pytest.fixture
        def headers(self, authclient):
            """Return an `Authorization` header for authclient."""

        @pytest.fixture
        def group(self, authclient):
            """Return a group with an authority matching authclient's."""

        @pytest.fixture
        def user(self, authclient):
            """Return a user with an authority matching authclient's."""

        @pytest.fixture
        def do_request(self, db_session, group, user, headers):
            def do_request(...):
                """Commit the db_session then make an API request with group, user and headers."""
            return do_request

The happy path test (`test_it()`) verifies that, with everything as set
up by the test fixtures, the API request succeeds and adds the user to
the group.

The error test (`test_it_errors_if_the_request_isnt_authenticated()`)
changes one thing about the fixtures set up (it deletes the
`Authorization` header) and asserts that the request 404s.

This approach makes mistakes in tests much less likely.

I have also gone through breaking specific parts of the context,
security, view and service code and testing that the expected test
fails. I did this for every functest in the class.

This commit also fixes one bug in the code: if the add-member API is called
with an invalid userid (one that isn't in `acct:{username}@{authority}`
format) the app crashes. The previous functests actually had a test for
this case, but the test was wrong:

    def test_it_returns_404_if_malformed_userid(
        self, app, factories, auth_client, db_session
    ):
        group = factories.Group()
        db_session.commit()

        res = app.post_json(
            "/api/groups/{pubid}/members/{userid}".format(
                pubid=group.pubid, userid="[email protected]"
            ),
            headers=self.authorization_header(auth_client),
            expect_errors=True,
        )

        assert res.status_code == 404

This test creates a `group` whose authority doesn't match that of the
`auth_client` and the request 404s for that reason before the code ever
gets to parsing the malformed userid. So the test passes. In fact if the
test had set up the group's authority correctly the app would crash on
the malformed userid. This commit replaces this test with a correct one.
@seanh seanh force-pushed the improve-add-member-api-functests branch from 0a7016c to 9ad7e91 Compare November 22, 2024 13:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant