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
« 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
5import responses
6from flask_testing import TestCase
7from openid.consumer.consumer import SUCCESS
8from pymacaroons import Macaroon
10from webapp.app import create_app
11from webapp.extensions import csrf
14class DeletePackageTest(TestCase):
15 def create_app(self):
16 app = create_app(testing=True)
17 app.secret_key = "test_key"
18 return app
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"
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"]
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
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")
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 )
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)
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")
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 )
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 )
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()
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()
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)
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()
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)
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 )
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()
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)
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()
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()
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)
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)
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)
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 )