Coverage for tests/publisher/test_publisher_views.py: 100%
251 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-18 22:11 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-18 22:11 +0000
1import unittest
2from urllib.parse import parse_qs, urlparse
3from webapp.app import app
4from tests.mock_data.mock_store_logic import sample_charm
5from unittest.mock import call, patch
7from canonicalwebteam.exceptions import (
8 PublisherMacaroonRefreshRequired,
9 StoreApiResponseErrorList,
10)
13class TestPublisherViews(unittest.TestCase):
14 @patch(
15 "webapp.decorators.login_required",
16 )
17 def setUp(self, mock_login_required):
18 mock_login_required.return_value = lambda: True
19 self.app = app
20 self.app.config["TESTING"] = True
21 self.app.config["WTF_CSRF_ENABLED"] = []
22 self.client = self.app.test_client()
23 self.set_session()
25 def set_session(self):
26 with self.client.session_transaction() as session:
27 session["account"] = {
28 "display-name": "test-display-name",
29 "email": "test-email",
30 "username": "test-username",
31 "id": "test-id",
32 }
33 session["account-auth"] = "test-auth"
35 def test_get_account_details_no_login(self):
36 with self.client.session_transaction() as session:
37 del session["account-auth"]
38 del session["account"]
39 res = self.client.get("/account/details")
40 self.assertEqual(res.status_code, 302)
42 @patch(
43 "webapp.authentication.is_authenticated",
44 return_value=True,
45 )
46 def test_get_account_details(self, mock_is_authenticated):
47 res = self.client.get("/account/details")
48 self.assertEqual(res.status_code, 200)
49 self.assertIn(b"test-display-name", res.data)
50 self.assertIn(b"test-email", res.data)
51 self.assertIn(b"test-username", res.data)
53 def test_solutions_csrf_token(self):
54 res = self.client.get("/api/solutions/csrf-token")
56 self.assertEqual(res.status_code, 200)
57 self.assertIn("csrf_token", res.get_json())
58 self.assertEqual(res.headers["Cache-Control"], "no-store")
59 self.assertIn("Cookie", res.headers["Vary"])
61 @patch("webapp.store_api.publisher_gateway.get_package_metadata")
62 def test_get_publisher(self, mock_get_package_metadata):
63 mock_get_package_metadata.return_value = {
64 **sample_charm,
65 "unlisted": False,
66 }
67 for endpoint in [
68 "listing",
69 "releases",
70 "publicise",
71 "collaboration",
72 "settings",
73 ]:
74 res = self.client.get(f"/test-entity/{endpoint}")
75 self.assertEqual(res.status_code, 200)
77 @patch("webapp.store_api.publisher_gateway.get_package_metadata")
78 def test_get_package(self, mock_get_package_metadata):
79 mock_get_package_metadata.return_value = {
80 "name": "test-package",
81 "unlisted": False,
82 }
83 res = self.client.get("/api/packages/test-entity")
84 self.assertEqual(res.status_code, 200)
85 self.assertEqual(
86 res.json,
87 {"success": True, "data": mock_get_package_metadata.return_value},
88 )
90 @patch("webapp.publisher.views.redis_cache.delete")
91 @patch("webapp.store_api.publisher_gateway.update_package_metadata")
92 def test_update_package(
93 self, mock_update_package_metadata, mock_cache_delete
94 ):
95 mock_update_package_metadata.return_value = {"name": "test-package"}
96 res = self.client.patch(
97 "/api/packages/test-entity", json={"key": "value"}
98 )
99 self.assertEqual(res.status_code, 200)
100 public_fields = [
101 "result.media",
102 "default-release",
103 "result.categories",
104 "result.publisher.display-name",
105 "result.title",
106 "result.unlisted",
107 "channel-map",
108 "result.deployable-on",
109 "result.bugs-url",
110 "result.website",
111 "result.summary",
112 "default-release.revision.metadata-yaml",
113 "default-release.revision.readme-md",
114 "result.links",
115 ]
116 public_cache_parts = {
117 "channel": None,
118 "fields": ",".join(sorted(public_fields)),
119 }
121 mock_cache_delete.assert_has_calls(
122 [
123 call("package_metadata:test-id:test-entity"),
124 call(("package_details:test-entity", public_cache_parts)),
125 call(("package:test-entity", public_cache_parts)),
126 call(("test-entity:details-overview", public_cache_parts)),
127 ]
128 )
129 self.assertEqual(mock_cache_delete.call_count, 4)
131 @patch("webapp.store_api.publisher_gateway.update_package_metadata")
132 def test_update_package_failure(self, mock_update_package_metadata):
133 mock_update_package_metadata.side_effect = StoreApiResponseErrorList(
134 "test-error", 500, []
135 )
136 res = self.client.patch(
137 "/api/packages/test-entity", json={"key": "value"}
138 )
139 self.assertEqual(res.status_code, 500)
140 self.assertEqual(res.json["success"], False)
142 @patch("webapp.store_api.publisher_gateway.update_package_metadata")
143 def test_update_package_unauthorized(self, mock_update_package_metadata):
144 mock_update_package_metadata.side_effect = StoreApiResponseErrorList(
145 "test-error", 500, [{"message": "unauthorized"}]
146 )
147 res = self.client.patch(
148 "/api/packages/test-entity", json={"key": "value"}
149 )
150 self.assertEqual(res.status_code, 500)
151 self.assertEqual(res.json["success"], False)
152 self.assertEqual(res.json["message"], "Package not found")
154 @patch("webapp.store_api.publisher_gateway.get_account_packages")
155 def test_list_page(
156 self,
157 mock_get_account_packages,
158 ):
159 mock_get_account_packages.return_value = [
160 {
161 "contact": "email",
162 "id": "ChgcZB3RhaDOnhkAv9cgRg52LhjBbDt8",
163 "media": [
164 {
165 "type": "icon",
166 "url": "https://example.com/icon.svg",
167 }
168 ],
169 "name": "postgresql",
170 "private": False,
171 "unlisted": False,
172 "publisher": {
173 "id": "test-id",
174 "display-name": "Canonical Data Platform",
175 },
176 "status": "published",
177 "type": "charm",
178 },
179 ]
181 with self.client.session_transaction() as session:
182 session["account"] = {
183 "id": "test-id",
184 "username": "test-username",
185 "email": "test@example.com",
186 }
188 res = self.client.get("/charms")
189 self.assertEqual(res.status_code, 200)
190 self.assertIn(b"postgresql", res.data)
191 self.assertIn(b"Published Charms", res.data)
192 self.assertIn(b"Listed", res.data)
193 self.assertIn(b"Solutions", res.data)
194 res = self.client.get("/bundles")
195 self.assertEqual(res.status_code, 200)
196 self.assertNotIn(b"postgresql", res.data)
198 @patch("webapp.publisher.views.get_publisher_solutions", return_value=[])
199 def test_solutions_page_empty_state(self, mock_get_publisher_solutions):
200 res = self.client.get("/solutions")
202 self.assertEqual(res.status_code, 200)
203 mock_get_publisher_solutions.assert_called_once_with("test-username")
204 self.assertIn(b"Solutions", res.data)
205 self.assertIn(b"No published Solutions available", res.data)
207 @patch("webapp.publisher.views.get_solution_categories", return_value=[])
208 @patch("webapp.publisher.views.get_user_teams_for_solutions", return_value=[])
209 @patch("webapp.publisher.views.get_solution_from_backend")
210 def test_edit_solution_handles_empty_deployable_on(
211 self,
212 mock_get_solution_from_backend,
213 mock_get_user_teams_for_solutions,
214 mock_get_solution_categories,
215 ):
216 mock_get_solution_from_backend.return_value = {
217 "hash": "test-hash",
218 "name": "test-solution",
219 "title": "Test Solution",
220 "summary": "Test summary",
221 "status": "published",
222 "revision": 1,
223 "deployable-on": [],
224 "compatibility": {},
225 "documentation": {},
226 "media": {},
227 "charms": [],
228 "maintainers": [],
229 "use_cases": [],
230 "useful_links": [],
231 "categories": [],
232 }
234 res = self.client.get("/solutions/edit/test-hash")
236 self.assertEqual(res.status_code, 200)
237 self.assertIn(b"Edit solution: Test Solution", res.data)
238 self.assertIn(b'name="platform" value="kubernetes"', res.data)
239 mock_get_solution_from_backend.assert_called_once_with(
240 "test-hash", prefer_authenticated=True
241 )
242 mock_get_user_teams_for_solutions.assert_called_once_with(
243 "test-username"
244 )
245 mock_get_solution_categories.assert_called_once()
247 @patch("webapp.publisher.views.redis_cache.get", return_value=None)
248 @patch("webapp.store_api.publisher_gateway.get_account_packages")
249 def test_list_page_expired_session_redirects_login(
250 self, mock_get_account_packages, _
251 ):
252 mock_get_account_packages.side_effect = (
253 PublisherMacaroonRefreshRequired()
254 )
256 response = self.client.get("/charms?page=1")
258 self.assertEqual(response.status_code, 302)
259 redirect = urlparse(response.location)
260 self.assertEqual(redirect.path, "/login")
261 self.assertEqual(
262 parse_qs(redirect.query).get("next"), ["/charms?page=1"]
263 )
264 with self.client.session_transaction() as session:
265 self.assertNotIn("account-auth", session)
266 self.assertNotIn("account", session)
268 def test_accept_invite(self):
269 res = self.client.get("/accept-invite")
270 self.assertIn(b"Success", res.data)
271 self.assertEqual(res.status_code, 200)
273 @patch("webapp.store_api.publisher_gateway.accept_invite")
274 def test_accept_post_invite(self, mock_accept_invite):
275 mock_accept_invite.return_value.status_code = 204
276 res = self.client.post(
277 "/accept-invite",
278 data={"token": "test-token", "package": "test-package"},
279 )
280 self.assertEqual(res.status_code, 200)
282 @patch("webapp.store_api.publisher_gateway.accept_invite")
283 def test_accept_post_invite_failed(self, mock_accept_invite):
284 mock_accept_invite.return_value.status_code = 401
285 mock_accept_invite.return_value.json.return_value = {
286 "error-list": [{"message": "Invite token has expired"}]
287 }
288 res = self.client.post(
289 "/accept-invite",
290 data={"token": "test-token", "package": "test-package"},
291 )
292 self.assertEqual(res.status_code, 401)
293 self.assertEqual(res.json["message"], "Invite token has expired")
295 @patch("webapp.store_api.publisher_gateway.accept_invite")
296 def test_accept_post_invite_error_list(self, mock_accept_invite):
297 mock_accept_invite.side_effect = StoreApiResponseErrorList(
298 "test-error", 400, [{"message": "Invite token has expired"}]
299 )
300 res = self.client.post(
301 "/accept-invite",
302 data={"token": "test-token", "package": "test-package"},
303 )
304 self.assertEqual(res.status_code, 400)
305 self.assertEqual(res.json["message"], "Invite token has expired")
307 @patch("webapp.store_api.publisher_gateway.accept_invite")
308 def test_accept_post_invite_macaroon_refresh_required(
309 self, mock_accept_invite
310 ):
311 mock_accept_invite.side_effect = PublisherMacaroonRefreshRequired()
312 res = self.client.post(
313 "/accept-invite",
314 data={"token": "test-token", "package": "test-package"},
315 )
316 self.assertEqual(res.status_code, 401)
317 self.assertTrue(res.json["reauth_required"])
318 self.assertFalse(res.json["success"])
319 with self.client.session_transaction() as sess:
320 self.assertNotIn("account-auth", sess)
322 @patch("webapp.store_api.publisher_gateway.reject_invite")
323 def test_reject_post_invite(self, mock_reject_invite):
324 mock_reject_invite.return_value.status_code = 204
325 res = self.client.post(
326 "/reject-invite",
327 data={"token": "test-token", "package": "test-package"},
328 )
329 self.assertEqual(res.status_code, 200)
331 @patch("webapp.store_api.publisher_gateway.get_collaborators")
332 def test_get_collaborators(self, mock_get_collaborators):
333 mock_get_collaborators.return_value = [{"name": "collaborator"}]
334 res = self.client.get("/api/packages/test-entity/collaborators")
335 self.assertEqual(res.status_code, 200)
337 @patch("webapp.store_api.publisher_gateway.get_collaborators")
338 def test_get_collaborators_failed(self, mock_get_collaborators):
339 mock_get_collaborators.side_effect = StoreApiResponseErrorList(
340 "test-error", 500, []
341 )
342 res = self.client.get("/api/packages/test-entity/collaborators")
343 self.assertEqual(res.status_code, 500)
345 @patch("webapp.store_api.publisher_gateway.get_pending_invites")
346 def test_get_pending_invites(self, mock_get_pending_invites):
347 mock_get_pending_invites.return_value = {
348 "invites": [{"name": "invite"}]
349 }
350 res = self.client.get("/api/packages/test-entity/invites")
351 self.assertEqual(res.status_code, 200)
353 @patch("webapp.utils.emailer.Emailer.send_email_template")
354 @patch("webapp.store_api.publisher_gateway.invite_collaborators")
355 def test_invite_collaborators(
356 self, mock_invite_collaborators, mock_send_email_template
357 ):
358 mock_send_email_template.return_value = None
359 mock_invite_collaborators.return_value = {
360 "tokens": [{"token": "test-token"}]
361 }
362 res = self.client.post(
363 "/api/packages/test-entity/invites",
364 data={"collaborators": "collaborator"},
365 )
366 self.assertEqual(res.status_code, 200)
368 @patch("webapp.store_api.publisher_gateway.revoke_invites")
369 def test_revoke_invite(self, mock_revoke_invites):
370 mock_revoke_invites.return_value.status_code = 204
371 res = self.client.delete(
372 "/api/packages/test-entity/invites",
373 data={"collaborator": "collaborator"},
374 )
375 self.assertEqual(res.status_code, 200)
377 def test_register_name(self):
378 res = self.client.get("/register-name")
379 self.assertEqual(res.status_code, 200)
380 self.assertIn(b"Register a new", res.data)
382 @patch("webapp.store_api.publisher_gateway.register_package_name")
383 def test_post_register_name(self, mock_register_package_name):
384 mock_register_package_name.return_value = True
385 res = self.client.post(
386 "/register-name",
387 data={
388 "name": "test-name",
389 "type": "charm",
390 "private": "private",
391 },
392 )
393 self.assertEqual(res.status_code, 302)
395 @patch("webapp.store_api.publisher_gateway.register_package_name")
396 def test_post_register_name_already_owned(
397 self, mock_register_package_name
398 ):
399 mock_register_package_name.side_effect = StoreApiResponseErrorList(
400 "test-error", 500, [{"code": "already-owned"}]
401 )
402 res = self.client.post(
403 "/register-name",
404 data={
405 "name": "test-name",
406 "type": "charm",
407 "private": "private",
408 },
409 )
410 self.assertEqual(res.status_code, 302)
411 self.assertIn("already_owned=True", res.headers["Location"])
413 @patch("webapp.store_api.publisher_gateway.register_package_name")
414 def test_post_register_name_api_error_relays_message(
415 self, mock_register_package_name
416 ):
417 # Publisher Gateway sometimes wraps more specific failures (e.g.
418 # name already taken by another publisher) under a generic
419 # "api-error" code. Rather than guessing what happened, relay the
420 # API's own message verbatim instead of misreporting it as an
421 # invalid name format error.
422 mock_register_package_name.side_effect = StoreApiResponseErrorList(
423 "test-error",
424 500,
425 [
426 {
427 "code": "api-error",
428 "message": "Name 'openbau' is already taken.",
429 }
430 ],
431 )
432 res = self.client.post(
433 "/register-name",
434 data={
435 "name": "openbau",
436 "type": "charm",
437 "private": "private",
438 },
439 )
440 self.assertEqual(res.status_code, 302)
441 self.assertIn(
442 "api_error_message=Name+'openbau'+is+already+taken.",
443 res.headers["Location"],
444 )
446 # And the message is rendered on the page as-is.
447 follow_res = self.client.get(res.headers["Location"])
448 self.assertIn(
449 b"Name 'openbau' is already taken.", follow_res.data
450 )
452 def test_register_name_dispute(self):
453 res = self.client.get("/register-name-dispute?entity-name=test-name")
454 self.assertEqual(res.status_code, 200)
455 self.assertIn(b"Claim the name", res.data)
457 def test_register_name_dispute_redirect(self):
458 res = self.client.get("/register-name-dispute")
459 self.assertEqual(res.status_code, 302)
461 def test_register_name_dispute_thank_you_redirect(self):
462 res = self.client.get("/register-name-dispute/thank-you")
463 self.assertEqual(res.status_code, 302)
465 def test_register_name_dispute_thank_you(self):
466 res = self.client.get(
467 "/register-name-dispute/thank-you?entity-name=test-name"
468 )
469 self.assertEqual(res.status_code, 200)
470 self.assertIn(
471 b"We will process the details provided with the name dispute.",
472 res.data,
473 )
475 @patch("webapp.store_api.publisher_gateway.unregister_package_name")
476 def test_delete_package(self, mock_unregister_package_name):
477 mock_unregister_package_name.return_value.status_code = 200
478 res = self.client.delete("/packages/test-package")
479 self.assertEqual(res.status_code, 200)
481 @patch("webapp.store_api.publisher_gateway.unregister_package_name")
482 def test_delete_package_failed(self, mock_unregister_package_name):
483 mock_unregister_package_name.return_value.status_code = 500
484 mock_unregister_package_name.return_value.json = lambda: {
485 "error-list": [{"message": "test-error"}]
486 }
487 res = self.client.delete("/packages/test-package")
488 self.assertEqual(res.status_code, 500)
490 @patch("webapp.store_api.publisher_gateway.create_track")
491 def test_post_create_track(self, mock_create_track):
492 mock_create_track.return_value.status_code = 201
493 mock_create_track.return_value.json = lambda: {"track": "test-track"}
494 res = self.client.post(
495 "/test-charm/create-track",
496 data={
497 "track-name": "test-track",
498 "version-pattern": "v1",
499 "auto-phasing-percentage": "50",
500 },
501 )
502 self.assertEqual(res.status_code, 201)
504 @patch("webapp.store_api.publisher_gateway.create_track")
505 def test_post_create_track_already_exists(self, mock_create_track):
506 mock_create_track.return_value.status_code = 409
507 mock_create_track.return_value.json = lambda: {"track": "test-track"}
508 res = self.client.post(
509 "/test-charm/create-track",
510 data={
511 "track-name": "test-track",
512 "version-pattern": "v1",
513 "auto-phasing-percentage": "50",
514 },
515 )
516 self.assertEqual(res.status_code, 409)
518 @patch("webapp.store_api.publisher_gateway.get_releases")
519 @patch("webapp.publisher.logic.process_releases")
520 @patch("webapp.publisher.logic.get_all_architectures")
521 def test_get_releases(
522 self,
523 mock_get_all_architectures,
524 mock_process_releases,
525 mock_get_releases,
526 ):
527 mock_get_releases.return_value = {
528 "channel-map": [],
529 "package": {"channels": []},
530 "revisions": [],
531 }
532 mock_process_releases.return_value = []
533 mock_get_all_architectures.return_value = []
534 self.set_session()
535 res = self.client.get("/api/packages/test-entity/releases")
536 self.assertEqual(res.status_code, 200)