|
4 | 4 | from django.db import transaction |
5 | 5 | from django.test import TestCase |
6 | 6 | from django.urls import reverse |
| 7 | +from rest_framework.exceptions import AuthenticationFailed |
7 | 8 |
|
8 | 9 | # Standard Library |
9 | 10 | import hashlib |
|
14 | 15 |
|
15 | 16 | # Third Party |
16 | 17 | import pytest |
| 18 | +import requests |
| 19 | +from rest_framework_simplejwt.settings import api_settings |
17 | 20 |
|
18 | 21 | # DocumentCloud |
| 22 | +from documentcloud.core.authentication import SquareletJWTAuthentication |
19 | 23 | from documentcloud.users.tests.factories import UserFactory |
20 | 24 |
|
21 | 25 |
|
@@ -93,3 +97,91 @@ def test_invalid_signature(self): |
93 | 97 | f"{user.mailkey}@uploads.documentcloud.org", sign=False |
94 | 98 | ) |
95 | 99 | assert response.status_code == 403 |
| 100 | + |
| 101 | + |
| 102 | +@pytest.mark.django_db() |
| 103 | +class TestSquareletJWTAuthentication: |
| 104 | + """Tests for lazy user provisioning during JWT authentication""" |
| 105 | + |
| 106 | + def token(self, user_uuid): |
| 107 | + """Build a minimal validated token carrying the user's uuid claim""" |
| 108 | + return {api_settings.USER_ID_CLAIM: str(user_uuid)} |
| 109 | + |
| 110 | + @mock.patch("documentcloud.core.authentication.squarelet_update_or_create") |
| 111 | + @mock.patch("documentcloud.core.authentication.squarelet_get") |
| 112 | + def test_existing_user(self, mock_get, mock_update): |
| 113 | + """A user that already exists locally is returned without a callback""" |
| 114 | + user = UserFactory() |
| 115 | + auth = SquareletJWTAuthentication() |
| 116 | + |
| 117 | + result = auth.get_user(self.token(user.uuid)) |
| 118 | + |
| 119 | + assert result == user |
| 120 | + mock_get.assert_not_called() |
| 121 | + mock_update.assert_not_called() |
| 122 | + |
| 123 | + @mock.patch( |
| 124 | + "documentcloud.core.authentication.squarelet_settings.DISABLE_CREATE", False |
| 125 | + ) |
| 126 | + @mock.patch("documentcloud.core.authentication.squarelet_update_or_create") |
| 127 | + @mock.patch("documentcloud.core.authentication.squarelet_get") |
| 128 | + def test_lazy_provision_missing_user(self, mock_get, mock_update): |
| 129 | + """A missing user is fetched from Squarelet, created, and returned""" |
| 130 | + missing_uuid = uuid.uuid4() |
| 131 | + data = {"preferred_username": "newuser", "organizations": []} |
| 132 | + mock_get.return_value.json.return_value = data |
| 133 | + # Simulate squarelet_update_or_create creating the local mirror row |
| 134 | + mock_update.side_effect = lambda _uuid, _data: UserFactory(uuid=missing_uuid) |
| 135 | + auth = SquareletJWTAuthentication() |
| 136 | + |
| 137 | + result = auth.get_user(self.token(missing_uuid)) |
| 138 | + |
| 139 | + assert result.uuid == missing_uuid |
| 140 | + mock_get.assert_called_once_with(f"/api/users/{missing_uuid}/") |
| 141 | + # The uuid comes off the JWT claim as a string, matching how the |
| 142 | + # webhook's pull_data task calls squarelet_update_or_create |
| 143 | + mock_update.assert_called_once_with(str(missing_uuid), data) |
| 144 | + |
| 145 | + @mock.patch("documentcloud.core.authentication.squarelet_update_or_create") |
| 146 | + @mock.patch("documentcloud.core.authentication.squarelet_get") |
| 147 | + def test_invalid_token_not_provisioned(self, mock_get, mock_update): |
| 148 | + """A token without a user claim must 401 without contacting Squarelet""" |
| 149 | + auth = SquareletJWTAuthentication() |
| 150 | + |
| 151 | + with pytest.raises(AuthenticationFailed): |
| 152 | + auth.get_user({}) |
| 153 | + |
| 154 | + mock_get.assert_not_called() |
| 155 | + mock_update.assert_not_called() |
| 156 | + |
| 157 | + @mock.patch( |
| 158 | + "documentcloud.core.authentication.squarelet_settings.DISABLE_CREATE", False |
| 159 | + ) |
| 160 | + @mock.patch("documentcloud.core.authentication.squarelet_update_or_create") |
| 161 | + @mock.patch("documentcloud.core.authentication.squarelet_get") |
| 162 | + def test_squarelet_fetch_fails(self, mock_get, mock_update): |
| 163 | + """If the Squarelet fetch fails, the request still 401s""" |
| 164 | + missing_uuid = uuid.uuid4() |
| 165 | + mock_get.side_effect = requests.exceptions.RequestException |
| 166 | + auth = SquareletJWTAuthentication() |
| 167 | + |
| 168 | + with pytest.raises(AuthenticationFailed): |
| 169 | + auth.get_user(self.token(missing_uuid)) |
| 170 | + |
| 171 | + mock_update.assert_not_called() |
| 172 | + |
| 173 | + @mock.patch( |
| 174 | + "documentcloud.core.authentication.squarelet_settings.DISABLE_CREATE", True |
| 175 | + ) |
| 176 | + @mock.patch("documentcloud.core.authentication.squarelet_update_or_create") |
| 177 | + @mock.patch("documentcloud.core.authentication.squarelet_get") |
| 178 | + def test_disable_create_skips_provisioning(self, mock_get, mock_update): |
| 179 | + """When SQUARELET_DISABLE_CREATE is set, missing users still 401""" |
| 180 | + missing_uuid = uuid.uuid4() |
| 181 | + auth = SquareletJWTAuthentication() |
| 182 | + |
| 183 | + with pytest.raises(AuthenticationFailed): |
| 184 | + auth.get_user(self.token(missing_uuid)) |
| 185 | + |
| 186 | + mock_get.assert_not_called() |
| 187 | + mock_update.assert_not_called() |
0 commit comments