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

1import requests 

2 

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 

11 

12from unittest.mock import patch, MagicMock 

13 

14 

15class LoginHandlerTest(TestCase): 

16 def setUp(self): 

17 self.api_url = "https://dashboard.snapcraft.io/dev/api/acl/" 

18 self.endpoint_url = "/login" 

19 

20 def create_app(self): 

21 app = create_app(testing=True) 

22 app.secret_key = "secret_key" 

23 app.config["WTF_CSRF_METHODS"] = [] 

24 

25 return app 

26 

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" 

31 

32 response = self.client.get(self.endpoint_url) 

33 assert response.status_code == 302 

34 self.assertEqual("http://localhost/", response.location) 

35 

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" 

40 

41 response = self.client.get(self.endpoint_url + "?next=/test") 

42 assert response.status_code == 302 

43 self.assertEqual("/test", response.location) 

44 

45 @responses.activate 

46 def test_login_handler_redirect(self): 

47 m = Macaroon() 

48 m.add_third_party_caveat("login.ubuntu.com", "key", "id") 

49 

50 serialized_macaroon = m.serialize() 

51 

52 responses.add( 

53 responses.Response( 

54 method="POST", 

55 url=self.api_url, 

56 json={"macaroon": serialized_macaroon}, 

57 status=200, 

58 ) 

59 ) 

60 

61 response = self.client.get(self.endpoint_url) 

62 

63 assert len(responses.calls) == 1 

64 assert response.status_code == 302 

65 

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 ) 

71 

72 response = self.client.get(self.endpoint_url) 

73 

74 assert len(responses.calls) == 1 

75 assert response.status_code == 502 

76 

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 ) 

82 

83 response = self.client.get(self.endpoint_url) 

84 

85 assert len(responses.calls) == 1 

86 assert response.status_code == 302 

87 self.assertEqual("/logout", response.location) 

88 

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 ) 

99 

100 response = self.client.get(self.endpoint_url) 

101 

102 assert response.status_code == 502 

103 

104 

105class AfterLoginHandlerTest(TestCase): 

106 def create_app(self): 

107 app = create_app(testing=True) 

108 

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 

114 

115 return after_login(self.mock_resp) 

116 

117 return app 

118 

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() 

134 

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 } 

145 

146 mock_get_account.return_value = { 

147 "username": self.mock_resp.nickname, 

148 "displayname": self.mock_resp.fullname, 

149 "email": email, 

150 "stores": [], 

151 } 

152 

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 ) 

174 

175 with self.client.session_transaction() as s: 

176 s["macaroon_root"] = self.root_macaroon 

177 self.client.get("/_test_after_login") 

178 

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 

186 

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"]) 

206 

207 with self.client.session_transaction() as s: 

208 s["macaroon_root"] = self.root_macaroon 

209 self.client.get("/_test_after_login") 

210 

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 

215 

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 ) 

237 

238 with self.client.session_transaction() as s: 

239 s["macaroon_root"] = self.root_macaroon 

240 self.client.get("/_test_after_login") 

241 

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 

246 

247 def test_after_login_exchanges_macaroons_and_clears_root_and_discharge( 

248 self, 

249 ): 

250 self.prepare_mock_response(MagicMock(), groups=["canonical"]) 

251 

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 

274 

275 response = self.client.get("/_test_after_login") 

276 

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 

282 

283 mock_exchange.assert_called_once() 

284 

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 

303 

304 with self.client.session_transaction() as s: 

305 s["macaroon_root"] = self.root_macaroon 

306 

307 response = self.client.get("/_test_after_login") 

308 

309 assert response.status_code == 302 

310 assert response.location == "/account/agreement" 

311 

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" 

318 

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 ) 

349 

350 with self.client.session_transaction() as s: 

351 s["macaroon_root"] = self.root_macaroon 

352 

353 response = self.client.get("/_test_after_login") 

354 

355 assert response.status_code == 302 

356 assert response.location == "/account/agreement" 

357 

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 

367 

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 ) 

385 

386 with self.client.session_transaction() as s: 

387 s["macaroon_root"] = self.root_macaroon 

388 

389 response = self.client.get("/_test_after_login") 

390 

391 assert response.status_code != 302 

392 with self.client.session_transaction() as s: 

393 assert "publisher" not in s