Coverage for tests/login/tests_login_handler.py: 100%

137 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-08 13:26 +0000

1import requests 

2 

3import responses 

4from flask_testing import TestCase 

5from pymacaroons import Macaroon 

6from webapp.app import create_app 

7 

8from unittest.mock import patch, MagicMock 

9 

10 

11class LoginHandlerTest(TestCase): 

12 def setUp(self): 

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

14 self.endpoint_url = "/login" 

15 

16 def create_app(self): 

17 app = create_app(testing=True) 

18 app.secret_key = "secret_key" 

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

20 

21 return app 

22 

23 def test_redirect_user_logged_in(self): 

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

25 s["publisher"] = "openid" 

26 s["macaroon_exchanged"] = "macaroon_exchanged" 

27 

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

29 assert response.status_code == 302 

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

31 

32 def test_redirect_user_logged_in_next_url(self): 

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

34 s["publisher"] = "openid" 

35 s["macaroon_exchanged"] = "macaroon_exchanged" 

36 

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

38 assert response.status_code == 302 

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

40 

41 @responses.activate 

42 def test_login_handler_redirect(self): 

43 m = Macaroon() 

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

45 

46 serialized_macaroon = m.serialize() 

47 

48 responses.add( 

49 responses.Response( 

50 method="POST", 

51 url=self.api_url, 

52 json={"macaroon": serialized_macaroon}, 

53 status=200, 

54 ) 

55 ) 

56 

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

58 

59 assert len(responses.calls) == 1 

60 assert response.status_code == 302 

61 

62 @responses.activate 

63 def test_login_api_500(self): 

64 responses.add( 

65 responses.Response(method="POST", url=self.api_url, status=500) 

66 ) 

67 

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

69 

70 assert len(responses.calls) == 1 

71 assert response.status_code == 502 

72 

73 @responses.activate 

74 def test_login_api_401(self): 

75 responses.add( 

76 responses.Response(method="POST", url=self.api_url, status=401) 

77 ) 

78 

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

80 

81 assert len(responses.calls) == 1 

82 assert response.status_code == 302 

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

84 

85 @responses.activate 

86 def test_login_connection_error(self): 

87 responses.add( 

88 responses.Response( 

89 method="POST", 

90 url=self.api_url, 

91 body=requests.exceptions.ConnectionError(), 

92 status=500, 

93 ) 

94 ) 

95 

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

97 

98 assert response.status_code == 502 

99 

100 

101class AfterLoginHandlerTest(TestCase): 

102 def create_app(self): 

103 app = create_app(testing=True) 

104 

105 # set up a fake route for testing the after_login function 

106 # since it is decorated with @open_id.after_login 

107 @app.route("/_test_after_login") 

108 def _test_after_login(): 

109 from webapp.login.views import after_login 

110 

111 return after_login(self.mock_resp) 

112 

113 return app 

114 

115 # creates a mocked responses for the get_account function 

116 # and the login response passed to after_login 

117 def prepare_mock_response( 

118 self, mock_get_account, email="test@test.com", groups=[] 

119 ): 

120 root = Macaroon(location="store", identifier="root", key="root-key") 

121 root.add_third_party_caveat( 

122 "login.ubuntu.com", "caveat-key", "caveat-id" 

123 ) 

124 self.root_macaroon = root.serialize() 

125 discharge = Macaroon( 

126 location="login.ubuntu.com", 

127 identifier="caveat-id", 

128 key="caveat-key", 

129 ).serialize() 

130 

131 self.mock_resp = MagicMock() 

132 self.mock_resp.nickname = "test" 

133 self.mock_resp.identity_url = "https://login.ubuntu.com/test" 

134 self.mock_resp.fullname = "Test" 

135 self.mock_resp.image = "test.png" 

136 self.mock_resp.email = email 

137 self.mock_resp.extensions = { 

138 "macaroon": MagicMock(discharge=discharge), 

139 "lp": MagicMock(is_member=groups), 

140 } 

141 

142 mock_get_account.return_value = { 

143 "username": self.mock_resp.nickname, 

144 "displayname": self.mock_resp.fullname, 

145 "email": email, 

146 "stores": [], 

147 } 

148 

149 @patch("webapp.login.views.ENVIRONMENT", "staging") 

150 @patch("webapp.login.views.dashboard.get_stores", return_value=[]) 

151 @patch("webapp.login.views.logic.get_stores", return_value=[]) 

152 @patch( 

153 "webapp.login.views.dashboard.get_validation_sets", return_value=None 

154 ) 

155 @patch( 

156 "webapp.login.views.publisher_gateway.exchange_dashboard_macaroons", 

157 return_value="exchanged-macaroon", 

158 ) 

159 @patch("webapp.login.views.dashboard.get_account") 

160 def test_is_canonical_true_if_email_ends_with_canonical_on_staging( 

161 self, 

162 mock_get_account, 

163 *_, 

164 ): 

165 # on test environments, we treat publisher's account as "canonical" 

166 # if their email is (at)canonical email 

167 self.prepare_mock_response( 

168 mock_get_account, email="test@canonical.com", groups=[] 

169 ) 

170 

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

172 s["macaroon_root"] = self.root_macaroon 

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

174 

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

176 publisher = s.get("publisher") 

177 assert publisher is not None 

178 assert publisher["is_canonical"] is True 

179 assert s["macaroon_exchanged"] == "exchanged-macaroon" 

180 assert "macaroon_root" not in s 

181 assert "macaroon_discharge" not in s 

182 

183 @patch("webapp.login.views.ENVIRONMENT", "production") 

184 @patch("webapp.login.views.dashboard.get_stores", return_value=[]) 

185 @patch("webapp.login.views.logic.get_stores", return_value=[]) 

186 @patch( 

187 "webapp.login.views.dashboard.get_validation_sets", return_value=None 

188 ) 

189 @patch( 

190 "webapp.login.views.publisher_gateway.exchange_dashboard_macaroons", 

191 return_value="exchanged-macaroon", 

192 ) 

193 @patch("webapp.login.views.dashboard.get_account") 

194 def test_is_canonical_true_if_member_of_team_on_production( 

195 self, 

196 mock_get_account, 

197 *_, 

198 ): 

199 # on production, we treat publisher's account as "canonical" 

200 # if they are a member of the canonical team 

201 self.prepare_mock_response(mock_get_account, groups=["canonical"]) 

202 

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

204 s["macaroon_root"] = self.root_macaroon 

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

206 

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

208 publisher = s.get("publisher") 

209 assert publisher is not None 

210 assert publisher["is_canonical"] is True 

211 

212 @patch("webapp.login.views.ENVIRONMENT", "production") 

213 @patch("webapp.login.views.dashboard.get_stores", return_value=[]) 

214 @patch("webapp.login.views.logic.get_stores", return_value=[]) 

215 @patch( 

216 "webapp.login.views.dashboard.get_validation_sets", return_value=None 

217 ) 

218 @patch( 

219 "webapp.login.views.publisher_gateway.exchange_dashboard_macaroons", 

220 return_value="exchanged-macaroon", 

221 ) 

222 @patch("webapp.login.views.dashboard.get_account") 

223 def test_is_canonical_false_if_not_member_of_team_on_production( 

224 self, 

225 mock_get_account, 

226 *_, 

227 ): 

228 # on production, we treat publisher's account as "canonical" 

229 # only if they are a member of the canonical team, not based on email 

230 self.prepare_mock_response( 

231 mock_get_account, email="test@canonical.com", groups=[] 

232 ) 

233 

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

235 s["macaroon_root"] = self.root_macaroon 

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

237 

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

239 publisher = s.get("publisher") 

240 assert publisher is not None 

241 assert publisher["is_canonical"] is False 

242 

243 def test_after_login_exchanges_macaroons_and_clears_root_and_discharge( 

244 self, 

245 ): 

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

247 

248 with patch( 

249 "webapp.login.views.dashboard.get_account", 

250 return_value={ 

251 "username": "test", 

252 "displayname": "Test", 

253 "email": "test@test.com", 

254 "stores": [], 

255 }, 

256 ), patch( 

257 "webapp.login.views.dashboard.get_validation_sets", 

258 return_value=None, 

259 ), patch( 

260 "webapp.login.views.dashboard.get_stores", return_value=[] 

261 ), patch( 

262 "webapp.login.views.logic.get_stores", return_value=[] 

263 ), patch( 

264 "webapp.login.views.publisher_gateway" 

265 ".exchange_dashboard_macaroons", 

266 return_value="exchanged-macaroon", 

267 ) as mock_exchange: 

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

269 s["macaroon_root"] = self.root_macaroon 

270 

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

272 

273 assert response.status_code == 302 

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

275 assert s["macaroon_exchanged"] == "exchanged-macaroon" 

276 assert "macaroon_root" not in s 

277 assert "macaroon_discharge" not in s 

278 

279 mock_exchange.assert_called_once()