Coverage for tests/publisher/snaps/tests_delete_package.py: 100%

205 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-25 22:09 +0000

1import time 

2from types import SimpleNamespace 

3from unittest.mock import patch 

4 

5import responses 

6from flask_testing import TestCase 

7from openid.consumer.consumer import SUCCESS 

8from pymacaroons import Macaroon 

9 

10from webapp.app import create_app 

11from webapp.extensions import csrf 

12 

13 

14class DeletePackageTest(TestCase): 

15 def create_app(self): 

16 app = create_app(testing=True) 

17 app.secret_key = "test_key" 

18 return app 

19 

20 def setUp(self): 

21 account = patch( 

22 "webapp.publisher.snaps.views.dashboard.get_account", 

23 return_value={"snaps": {}}, 

24 ) 

25 account.start() 

26 self.addCleanup(account.stop) 

27 self.identity_url = "https://login.ubuntu.com/test-user" 

28 self.api_url = "https://api.charmhub.io/v1/snap/test-snap" 

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

30 root.add_third_party_caveat( 

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

32 ) 

33 self.root = root.serialize() 

34 self.discharge = Macaroon( 

35 location="login.ubuntu.com", 

36 identifier="caveat-id", 

37 key="caveat-key", 

38 ).serialize() 

39 self.resp = SimpleNamespace( 

40 identity_url=self.identity_url, 

41 extensions={"macaroon": SimpleNamespace(discharge=self.discharge)}, 

42 ) 

43 with self.client.session_transaction() as session: 

44 session["publisher"] = { 

45 "identity_url": self.identity_url, 

46 "email": "test@example.com", 

47 "nickname": "test-user", 

48 "fullname": "Test User", 

49 "image": None, 

50 "stores": [], 

51 } 

52 session["macaroon_exchanged"] = "exchanged-token" 

53 

54 def initiate(self): 

55 response = self.client.delete("/packages/test-snap") 

56 self.assertEqual(response.status_code, 202) 

57 data = response.get_json() 

58 self.assertTrue(data["authorization_required"]) 

59 return data["redirect_url"] 

60 

61 def prepare_callback(self): 

62 url = self.initiate() 

63 with self.client.session_transaction() as session: 

64 session["pending_snap_unregister"]["root_macaroon"] = self.root 

65 session.modified = True 

66 return url 

67 

68 def callback(self, url, resp=None): 

69 with ( 

70 patch("flask_openid.Consumer") as consumer, 

71 patch( 

72 "flask_openid.OpenIDResponse", 

73 return_value=resp or self.resp, 

74 ), 

75 ): 

76 consumer.return_value.complete.return_value.status = SUCCESS 

77 return self.client.get(f"{url}?openid_complete=yes") 

78 

79 def assert_credentials_discarded(self): 

80 with self.client.session_transaction() as session: 

81 self.assertNotIn("pending_snap_unregister", session) 

82 self.assertNotIn("macaroon_root", session) 

83 self.assertNotIn("macaroon_discharge", session) 

84 self.assertEqual(session["macaroon_exchanged"], "exchanged-token") 

85 self.assertEqual( 

86 session["publisher"]["identity_url"], self.identity_url 

87 ) 

88 

89 @responses.activate 

90 def test_confirmation_defers_upstream_delete(self): 

91 self.initiate() 

92 self.assertEqual(len(responses.calls), 0) 

93 with self.client.session_transaction() as session: 

94 pending = session["pending_snap_unregister"] 

95 self.assertEqual(pending["snap_name"], "test-snap") 

96 self.assertEqual(pending["identity_url"], self.identity_url) 

97 self.assertGreater(pending["expires_at"], time.time()) 

98 self.assertNotIn("root_macaroon", pending) 

99 self.assertNotIn("developer_token", session) 

100 

101 @patch("webapp.login.views.open_id.try_login") 

102 @patch("webapp.login.views.authentication.request_macaroon") 

103 def test_authorization_requests_fresh_discharge(self, request, try_login): 

104 url = self.initiate() 

105 request.return_value = self.root 

106 try_login.return_value = ("", 302) 

107 response = self.client.get(url) 

108 self.assertEqual(response.status_code, 302) 

109 request.assert_called_once_with() 

110 self.assertEqual( 

111 try_login.call_args.kwargs["extensions"][0].caveat_id, 

112 "caveat-id", 

113 ) 

114 with self.client.session_transaction() as session: 

115 self.assertEqual( 

116 session["pending_snap_unregister"]["root_macaroon"], 

117 self.root, 

118 ) 

119 self.assertEqual(session["macaroon_exchanged"], "exchanged-token") 

120 

121 @responses.activate 

122 def test_callback_unregisters_with_bound_legacy_credentials(self): 

123 url = self.prepare_callback() 

124 bound = Macaroon.deserialize(self.root).prepare_for_request( 

125 Macaroon.deserialize(self.discharge) 

126 ) 

127 responses.add( 

128 responses.DELETE, 

129 self.api_url, 

130 json={}, 

131 status=200, 

132 match=[ 

133 responses.matchers.header_matcher( 

134 { 

135 "Authorization": ( 

136 f"macaroon root={self.root}, " 

137 f"discharge={bound.serialize()}" 

138 ) 

139 } 

140 ) 

141 ], 

142 ) 

143 response = self.callback(url) 

144 self.assertEqual(response.status_code, 302) 

145 self.assertEqual(response.location, "/snaps") 

146 self.assertEqual(len(responses.calls), 1) 

147 self.assert_credentials_discarded() 

148 with self.client.session_transaction() as session: 

149 self.assertIn( 

150 ("positive", "test-snap has been unregistered."), 

151 session["_flashes"], 

152 ) 

153 

154 @responses.activate 

155 def test_upstream_error_is_shown_and_credentials_discarded(self): 

156 url = self.prepare_callback() 

157 responses.add( 

158 responses.DELETE, 

159 self.api_url, 

160 json={"error-list": [{"message": "Cannot unregister this snap"}]}, 

161 status=400, 

162 ) 

163 self.callback(url) 

164 self.assert_credentials_discarded() 

165 with self.client.session_transaction() as session: 

166 self.assertIn( 

167 ("negative", "Cannot unregister this snap"), 

168 session["_flashes"], 

169 ) 

170 

171 @responses.activate 

172 def test_expired_callback_does_not_unregister(self): 

173 url = self.prepare_callback() 

174 with self.client.session_transaction() as session: 

175 session["pending_snap_unregister"]["expires_at"] = time.time() - 1 

176 session.modified = True 

177 self.callback(url) 

178 self.assertEqual(len(responses.calls), 0) 

179 self.assert_credentials_discarded() 

180 

181 @responses.activate 

182 def test_different_sso_identity_does_not_unregister(self): 

183 url = self.prepare_callback() 

184 self.resp.identity_url = "https://login.ubuntu.com/another-user" 

185 self.callback(url) 

186 self.assertEqual(len(responses.calls), 0) 

187 self.assert_credentials_discarded() 

188 

189 @responses.activate 

190 def test_changed_session_identity_does_not_unregister(self): 

191 url = self.prepare_callback() 

192 with self.client.session_transaction() as session: 

193 session["publisher"]["identity_url"] = "another-user" 

194 session.modified = True 

195 self.callback(url) 

196 self.assertEqual(len(responses.calls), 0) 

197 with self.client.session_transaction() as session: 

198 self.assertNotIn("pending_snap_unregister", session) 

199 

200 @responses.activate 

201 def test_missing_or_wrong_action_does_not_unregister(self): 

202 url = self.prepare_callback() 

203 self.callback("/login/unregister-snap/wrong-action") 

204 self.callback(url) 

205 self.assertEqual(len(responses.calls), 0) 

206 self.assert_credentials_discarded() 

207 

208 @responses.activate 

209 def test_second_callback_does_not_unregister_again(self): 

210 url = self.prepare_callback() 

211 responses.add(responses.DELETE, self.api_url, json={}, status=200) 

212 self.callback(url) 

213 self.callback(url) 

214 self.assertEqual(len(responses.calls), 1) 

215 

216 def test_overlapping_confirmation_does_not_replace_pending_action(self): 

217 self.initiate() 

218 response = self.client.delete("/packages/another-snap") 

219 self.assertEqual(response.status_code, 409) 

220 with self.client.session_transaction() as session: 

221 self.assertEqual( 

222 session["pending_snap_unregister"]["snap_name"], "test-snap" 

223 ) 

224 

225 def test_cancelled_sso_cleans_up_and_preserves_login(self): 

226 url = self.prepare_callback() 

227 response = self.client.get( 

228 f"{url}?openid_complete=yes&openid.mode=cancel" 

229 ) 

230 self.assertEqual(response.status_code, 302) 

231 response = self.client.get(response.location) 

232 self.assertEqual(response.location, "/snaps") 

233 self.assert_credentials_discarded() 

234 

235 def test_logout_discards_pending_action(self): 

236 self.prepare_callback() 

237 self.client.get("/logout") 

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

239 self.assertNotIn("pending_snap_unregister", session) 

240 

241 def test_abandoned_credentials_are_removed_on_next_request_after_expiry( 

242 self, 

243 ): 

244 self.prepare_callback() 

245 with self.client.session_transaction() as session: 

246 session["pending_snap_unregister"]["expires_at"] = time.time() - 1 

247 session.modified = True 

248 self.client.get("/snaps") 

249 self.assert_credentials_discarded() 

250 

251 @responses.activate 

252 def test_missing_discharge_does_not_unregister(self): 

253 url = self.prepare_callback() 

254 self.resp.extensions = {} 

255 self.callback(url) 

256 self.assertEqual(len(responses.calls), 0) 

257 self.assert_credentials_discarded() 

258 

259 @patch("webapp.login.views.open_id.try_login") 

260 @patch("webapp.login.views.authentication.request_macaroon") 

261 def test_normal_cookie_restored_after_completion(self, request, try_login): 

262 with self.client.session_transaction() as session: 

263 initial = dict(session) 

264 url = self.initiate() 

265 request.return_value = self.root 

266 try_login.return_value = ("", 302) 

267 self.client.get(url) 

268 with responses.RequestsMock() as mocked: 

269 mocked.add(responses.DELETE, self.api_url, json={}, status=200) 

270 self.callback(url) 

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

272 self.assertIn(b"test-snap has been unregistered.", response.data) 

273 with self.client.session_transaction() as session: 

274 self.assertEqual(dict(session), initial) 

275 

276 def test_unauthenticated_confirmation_redirects_to_login(self): 

277 self.client.get("/logout") 

278 response = self.client.delete("/packages/test-snap") 

279 self.assertEqual(response.status_code, 302) 

280 self.assertIn("/login", response.location) 

281 

282 def test_confirmation_requires_csrf(self): 

283 csrf.init_app(self.app) 

284 response = self.client.delete("/packages/test-snap") 

285 self.assertEqual(response.status_code, 400) 

286 with self.client.session_transaction() as session: 

287 self.assertNotIn("pending_snap_unregister", session) 

288 

289 @responses.activate 

290 def test_retained_credentials_still_require_fresh_authorization(self): 

291 with self.client.session_transaction() as session: 

292 session.pop("macaroon_exchanged") 

293 session["macaroon_root"] = self.root 

294 session["macaroon_discharge"] = self.discharge 

295 self.initiate() 

296 self.assertEqual(len(responses.calls), 0) 

297 with self.client.session_transaction() as session: 

298 self.assertEqual( 

299 session["pending_snap_unregister"]["snap_name"], "test-snap" 

300 ) 

301 self.assertNotIn( 

302 "root_macaroon", session["pending_snap_unregister"] 

303 )