Coverage for tests/login/tests_login_handler.py: 100%
185 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 22:23 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 22:23 +0000
1import requests
3import responses
4from flask_testing import TestCase
5from pymacaroons import Macaroon
6from canonicalwebteam.exceptions import (
7 PublisherAgreementNotSigned,
8 StoreApiResponseErrorList,
9)
10from webapp.app import create_app
12from unittest.mock import patch, MagicMock
15class LoginHandlerTest(TestCase):
16 def setUp(self):
17 self.api_url = "https://dashboard.snapcraft.io/dev/api/acl/"
18 self.endpoint_url = "/login"
20 def create_app(self):
21 app = create_app(testing=True)
22 app.secret_key = "secret_key"
23 app.config["WTF_CSRF_METHODS"] = []
25 return app
27 def test_redirect_user_logged_in(self):
28 with self.client.session_transaction() as s:
29 s["publisher"] = "openid"
30 s["macaroon_exchanged"] = "macaroon_exchanged"
32 response = self.client.get(self.endpoint_url)
33 assert response.status_code == 302
34 self.assertEqual("http://localhost/", response.location)
36 def test_redirect_user_logged_in_next_url(self):
37 with self.client.session_transaction() as s:
38 s["publisher"] = "openid"
39 s["macaroon_exchanged"] = "macaroon_exchanged"
41 response = self.client.get(self.endpoint_url + "?next=/test")
42 assert response.status_code == 302
43 self.assertEqual("/test", response.location)
45 @responses.activate
46 def test_login_handler_redirect(self):
47 m = Macaroon()
48 m.add_third_party_caveat("login.ubuntu.com", "key", "id")
50 serialized_macaroon = m.serialize()
52 responses.add(
53 responses.Response(
54 method="POST",
55 url=self.api_url,
56 json={"macaroon": serialized_macaroon},
57 status=200,
58 )
59 )
61 response = self.client.get(self.endpoint_url)
63 assert len(responses.calls) == 1
64 assert response.status_code == 302
66 @responses.activate
67 def test_login_api_500(self):
68 responses.add(
69 responses.Response(method="POST", url=self.api_url, status=500)
70 )
72 response = self.client.get(self.endpoint_url)
74 assert len(responses.calls) == 1
75 assert response.status_code == 502
77 @responses.activate
78 def test_login_api_401(self):
79 responses.add(
80 responses.Response(method="POST", url=self.api_url, status=401)
81 )
83 response = self.client.get(self.endpoint_url)
85 assert len(responses.calls) == 1
86 assert response.status_code == 302
87 self.assertEqual("/logout", response.location)
89 @responses.activate
90 def test_login_connection_error(self):
91 responses.add(
92 responses.Response(
93 method="POST",
94 url=self.api_url,
95 body=requests.exceptions.ConnectionError(),
96 status=500,
97 )
98 )
100 response = self.client.get(self.endpoint_url)
102 assert response.status_code == 502
105class AfterLoginHandlerTest(TestCase):
106 def create_app(self):
107 app = create_app(testing=True)
109 # set up a fake route for testing the after_login function
110 # since it is decorated with @open_id.after_login
111 @app.route("/_test_after_login")
112 def _test_after_login():
113 from webapp.login.views import after_login
115 return after_login(self.mock_resp)
117 return app
119 # creates a mocked responses for the get_account function
120 # and the login response passed to after_login
121 def prepare_mock_response(
122 self, mock_get_account, email="test@test.com", groups=[]
123 ):
124 root = Macaroon(location="store", identifier="root", key="root-key")
125 root.add_third_party_caveat(
126 "login.ubuntu.com", "caveat-key", "caveat-id"
127 )
128 self.root_macaroon = root.serialize()
129 discharge = Macaroon(
130 location="login.ubuntu.com",
131 identifier="caveat-id",
132 key="caveat-key",
133 ).serialize()
135 self.mock_resp = MagicMock()
136 self.mock_resp.nickname = "test"
137 self.mock_resp.identity_url = "https://login.ubuntu.com/test"
138 self.mock_resp.fullname = "Test"
139 self.mock_resp.image = "test.png"
140 self.mock_resp.email = email
141 self.mock_resp.extensions = {
142 "macaroon": MagicMock(discharge=discharge),
143 "lp": MagicMock(is_member=groups),
144 }
146 mock_get_account.return_value = {
147 "username": self.mock_resp.nickname,
148 "displayname": self.mock_resp.fullname,
149 "email": email,
150 "stores": [],
151 }
153 @patch("webapp.login.views.ENVIRONMENT", "staging")
154 @patch("webapp.login.views.dashboard.get_stores", return_value=[])
155 @patch("webapp.login.views.logic.get_stores", return_value=[])
156 @patch(
157 "webapp.login.views.dashboard.get_validation_sets", return_value=None
158 )
159 @patch(
160 "webapp.login.views.publisher_gateway.exchange_dashboard_macaroons",
161 return_value="exchanged-macaroon",
162 )
163 @patch("webapp.login.views.dashboard.get_account")
164 def test_is_canonical_true_if_email_ends_with_canonical_on_staging(
165 self,
166 mock_get_account,
167 *_,
168 ):
169 # on test environments, we treat publisher's account as "canonical"
170 # if their email is (at)canonical email
171 self.prepare_mock_response(
172 mock_get_account, email="test@canonical.com", groups=[]
173 )
175 with self.client.session_transaction() as s:
176 s["macaroon_root"] = self.root_macaroon
177 self.client.get("/_test_after_login")
179 with self.client.session_transaction() as s:
180 publisher = s.get("publisher")
181 assert publisher is not None
182 assert publisher["is_canonical"] is True
183 assert s["macaroon_exchanged"] == "exchanged-macaroon"
184 assert "macaroon_root" not in s
185 assert "macaroon_discharge" not in s
187 @patch("webapp.login.views.ENVIRONMENT", "production")
188 @patch("webapp.login.views.dashboard.get_stores", return_value=[])
189 @patch("webapp.login.views.logic.get_stores", return_value=[])
190 @patch(
191 "webapp.login.views.dashboard.get_validation_sets", return_value=None
192 )
193 @patch(
194 "webapp.login.views.publisher_gateway.exchange_dashboard_macaroons",
195 return_value="exchanged-macaroon",
196 )
197 @patch("webapp.login.views.dashboard.get_account")
198 def test_is_canonical_true_if_member_of_team_on_production(
199 self,
200 mock_get_account,
201 *_,
202 ):
203 # on production, we treat publisher's account as "canonical"
204 # if they are a member of the canonical team
205 self.prepare_mock_response(mock_get_account, groups=["canonical"])
207 with self.client.session_transaction() as s:
208 s["macaroon_root"] = self.root_macaroon
209 self.client.get("/_test_after_login")
211 with self.client.session_transaction() as s:
212 publisher = s.get("publisher")
213 assert publisher is not None
214 assert publisher["is_canonical"] is True
216 @patch("webapp.login.views.ENVIRONMENT", "production")
217 @patch("webapp.login.views.dashboard.get_stores", return_value=[])
218 @patch("webapp.login.views.logic.get_stores", return_value=[])
219 @patch(
220 "webapp.login.views.dashboard.get_validation_sets", return_value=None
221 )
222 @patch(
223 "webapp.login.views.publisher_gateway.exchange_dashboard_macaroons",
224 return_value="exchanged-macaroon",
225 )
226 @patch("webapp.login.views.dashboard.get_account")
227 def test_is_canonical_false_if_not_member_of_team_on_production(
228 self,
229 mock_get_account,
230 *_,
231 ):
232 # on production, we treat publisher's account as "canonical"
233 # only if they are a member of the canonical team, not based on email
234 self.prepare_mock_response(
235 mock_get_account, email="test@canonical.com", groups=[]
236 )
238 with self.client.session_transaction() as s:
239 s["macaroon_root"] = self.root_macaroon
240 self.client.get("/_test_after_login")
242 with self.client.session_transaction() as s:
243 publisher = s.get("publisher")
244 assert publisher is not None
245 assert publisher["is_canonical"] is False
247 def test_after_login_exchanges_macaroons_and_clears_root_and_discharge(
248 self,
249 ):
250 self.prepare_mock_response(MagicMock(), groups=["canonical"])
252 with patch(
253 "webapp.login.views.dashboard.get_account",
254 return_value={
255 "username": "test",
256 "displayname": "Test",
257 "email": "test@test.com",
258 "stores": [],
259 },
260 ), patch(
261 "webapp.login.views.dashboard.get_validation_sets",
262 return_value=None,
263 ), patch(
264 "webapp.login.views.dashboard.get_stores", return_value=[]
265 ), patch(
266 "webapp.login.views.logic.get_stores", return_value=[]
267 ), patch(
268 "webapp.login.views.publisher_gateway"
269 ".exchange_dashboard_macaroons",
270 return_value="exchanged-macaroon",
271 ) as mock_exchange:
272 with self.client.session_transaction() as s:
273 s["macaroon_root"] = self.root_macaroon
275 response = self.client.get("/_test_after_login")
277 assert response.status_code == 302
278 with self.client.session_transaction() as s:
279 assert s["macaroon_exchanged"] == "exchanged-macaroon"
280 assert "macaroon_root" not in s
281 assert "macaroon_discharge" not in s
283 mock_exchange.assert_called_once()
285 @patch("webapp.login.views.dashboard.get_validation_sets")
286 @patch("webapp.login.views.dashboard.get_account")
287 @patch(
288 "webapp.login.views.publisher_gateway" ".exchange_dashboard_macaroons",
289 return_value="exchanged-macaroon",
290 )
291 def test_after_login_agreement_not_signed_keeps_user_authenticated(
292 self,
293 _mock_exchange,
294 mock_get_account,
295 _mock_validation_sets,
296 ):
297 # Regression test for issue #5788: a publisher who has not yet
298 # accepted the developer Terms & Conditions must still end up with an
299 # authenticated session and be guided to the agreement page, rather
300 # than dead-ending in a redirect loop / 404.
301 self.prepare_mock_response(MagicMock(), groups=[])
302 mock_get_account.side_effect = PublisherAgreementNotSigned
304 with self.client.session_transaction() as s:
305 s["macaroon_root"] = self.root_macaroon
307 response = self.client.get("/_test_after_login")
309 assert response.status_code == 302
310 assert response.location == "/account/agreement"
312 with self.client.session_transaction() as s:
313 publisher = s.get("publisher")
314 assert publisher is not None
315 assert publisher["nickname"] == self.mock_resp.nickname
316 assert publisher["email"] == self.mock_resp.email
317 assert s["macaroon_exchanged"] == "exchanged-macaroon"
319 @patch("webapp.login.views.dashboard.get_validation_sets")
320 @patch("webapp.login.views.dashboard.get_account")
321 @patch("webapp.login.views.publisher_gateway.exchange_dashboard_macaroons")
322 def test_after_login_account_not_found_redirects_to_agreement(
323 self,
324 mock_exchange,
325 _mock_get_account,
326 _mock_validation_sets,
327 ):
328 # Regression test for issue #5788: a brand-new publisher who has never
329 # accepted the developer Terms & Conditions has no publisher account
330 # yet, so the macaroon exchange fails with "account-not-found".
331 # Instead of returning a 404, the user must be redirected to the
332 # agreement page with an authenticated session and the dashboard
333 # macaroons preserved, so accepting the agreement can create the
334 # account.
335 self.prepare_mock_response(MagicMock(), groups=[])
336 mock_exchange.side_effect = StoreApiResponseErrorList(
337 "The api returned a list of errors",
338 404,
339 [
340 {
341 "code": "account-not-found",
342 "message": (
343 "The account specified in the dashboard "
344 "macaroons was not found"
345 ),
346 }
347 ],
348 )
350 with self.client.session_transaction() as s:
351 s["macaroon_root"] = self.root_macaroon
353 response = self.client.get("/_test_after_login")
355 assert response.status_code == 302
356 assert response.location == "/account/agreement"
358 with self.client.session_transaction() as s:
359 publisher = s.get("publisher")
360 assert publisher is not None
361 assert publisher["nickname"] == self.mock_resp.nickname
362 # Dashboard macaroons must be preserved so the agreement POST is
363 # authorized and can onboard the account.
364 assert "macaroon_root" in s
365 assert "macaroon_discharge" in s
366 assert "macaroon_exchanged" not in s
368 @patch("webapp.login.views.dashboard.get_validation_sets")
369 @patch("webapp.login.views.dashboard.get_account")
370 @patch("webapp.login.views.publisher_gateway.exchange_dashboard_macaroons")
371 def test_after_login_reraises_other_exchange_errors(
372 self,
373 mock_exchange,
374 _mock_get_account,
375 _mock_validation_sets,
376 ):
377 # Exchange failures unrelated to onboarding must not be swallowed as
378 # an agreement redirect; they should surface as a server error.
379 self.prepare_mock_response(MagicMock(), groups=[])
380 mock_exchange.side_effect = StoreApiResponseErrorList(
381 "The api returned a list of errors",
382 500,
383 [{"code": "some-other-error", "message": "boom"}],
384 )
386 with self.client.session_transaction() as s:
387 s["macaroon_root"] = self.root_macaroon
389 response = self.client.get("/_test_after_login")
391 assert response.status_code != 302
392 with self.client.session_transaction() as s:
393 assert "publisher" not in s