Testing your applications ☑️

If you’re using ska-aaa-authhelpers to secure your app, then it will reject unauthorised API calls with a 403 error. However, that can make it harder to write integration tests to verify application behaviour.

Integration testing

In your tests, you can use mint_test_token to generate an access token, and set up a TestClient that passes that token in an HTTP Authorization header, just as client apps will do in a deployed environment.

Sample integration tests

from fastapi.testclient import TestClient
from ska_aaa_authhelpers import Role
from ska_aaa_authhelpers.test_helpers import mint_test_token

from my_app import MY_SCOPES, MY_SERVICE_ID, app

# If you want the same authorisation, you can share one client instance
# across multiple tests...
token = mint_test_token(audience=MY_SERVICE_ID, scopes=MY_SCOPES, roles=Role.SW_ENGINEER)
client = TestClient(app, headers={"Authorization": f"Bearer {token}"})


def test_get_item1():
    response = client.get("/item/1")
    assert response.status_code == 200
    assert response.json() == {"expected": "result1"}


def test_get_item2():
    response = client.get("/item/2")
    assert response.status_code == 200
    assert response.json() == {"expected": "result2"}


def test_other_user():
    # Or you can create and pass separate tokens for different test scenarios:
    tkn = mint_test_token(
        user_id="Different user", audience=MY_SERVICE_ID, scopes=MY_SCOPES, roles=Role.SW_ENGINEER
    )
    with TestClient(my_app) as client:
        resp = client.get("/item/1", headers={"Authorization": f"Bearer {tkn}"})
        assert resp.status_code == 403

Tip

If you have installed fastapi[standard] or if you install this library with the optional scripts dependency, as ska_aaa_authhelpers[scripts] then you also have mint_test_token available as a CLI tool…

$ mint_test_token --help

…you can use this to generate tokens to copy-paste for development, testing or use in continuous integration environments.

Replacing the public keys

However, requests made with these tokens will still fail because they are signed with our own test private key, rather than the Entra ID private key controlled by Microsoft. Tokens we sign can’t be validated with Microsoft’s public keys, and we can’t issue a token with their private key, so the only option is to replace the default public keys with test public keys that match our test private key.

One way to make this work without any changes to your application code is by enabling a Pytest fixture that automatically replaces ska_aaa_authhelpers.jwt.DEFAULT_PUBLIC_KEYS with ska_aaa_authhelpers.test_helpers.TEST_PUBLIC_KEYS. For example:

import pytest
from ska_aaa_authhelpers.test_helpers import monkeypatch_pubkeys

# put this in conftest.py
@pytest.fixture(scope="session", autouse=True)
def patch_pubkeys():
    monkeypatch_pubkeys()

If you run into any issues or find global patching won’t work for you, speak to the Auth Helpers authors or start a discussion on #help-aaa .

Unit testing custom authorisation logic

As discussed in Securing Your App most apps will involve some amount of custom authorisation logic.

If you structure the core of your authorisation logic as simple combinations of “pure” functions, it becomes much easier to unit test in a quick, deterministic way. For example, let’s imagine we were trying to define some rules around who can edit proposals…

from ska_aaa_authhelpers import AuthContext, Role

def owner_of_proposal(ctx: AuthContext, owners_group_id: GroupID) -> bool:
    return owners_group_id in ctx.principals

def is_skao_staff(ctx: AuthContext) -> bool:
    return Role.SKAO_STAFF in ctx.roles

def orphan_proposal(proposal: Proposal) -> bool:
    return proposal.owners_group.is_empty()

def allowed_to_edit_proposal(ctx: AuthContext, proposal: Proposal) -> (bool, str):
    if (
        is_skao_staff(ctx)
        or owner_of_proposal(ctx, proposal.owners_group.id)
        or orphan_proposal(proposal)
    ):
        return True, ""
    return False, "Only staff or owners may update proposals, except in the case of orphaned proposals with no owners."

Because these functions are so simple and depend entirely on their arguments, it becomes easier to make sure you’ve exhaustively covered all the cases you need:

@pytest.mark.parameterize(
    ("auth", "expected"),
    (
        (AuthContext(roles={Role.SKAO_STAFF}), True),
        (AuthContext(roles={Role.SW_ENGINEER}), False),
        (AuthContext(roles={Role.TELESCOPE_OPERATOR}), False),
    ),
)
def test_is_skao_staff(auth, expected):
    assert is_skao_staff(auth)

Your view function might look something like this, but you won’t need to worry as much about about exercising authorisation behaviour in your integration tests because you already have confidence in the logic from your comprehensive unit tests.

@app.put("/proposal/{id}")
def update_proposal(
    id: str,
    data: ProposalData,
    auth: Annotated[
        AuthContext, Requires(scopes={"proposal:write"}, roles={Role.SKAO_STAFF, Role.ASTRONOMER})
    ],
):
    proposal = database.get_proposal(id)
    allowed, msg = allowed_to_edit_proposal(ctx, proposal)
    if allowed:
        return database.update_proposal(data)
    else:
        raise AuthFailError(msg)