Coverage for tests/publisher/snaps/test_builds.py: 99%
144 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 22:23 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 22:23 +0000
1import unittest
2from unittest.mock import patch, MagicMock
4from webapp.app import create_app
5from webapp.publisher.snaps.builds import map_build_and_upload_states
6from webapp.publisher.snaps.build_views import (
7 extract_github_repository,
8 get_builds,
9)
12class TestBuildStateMapper(unittest.TestCase):
13 def test_build_state_mappings(self):
14 combinations = [
15 ("Needs building", "Unscheduled", "building_soon"),
16 ("Needs building", "Pending", "building_soon"),
17 ("Needs building", "Failed to upload", "building_soon"),
18 (
19 "Needs building",
20 "Failed to release to channels",
21 "building_soon",
22 ),
23 ("Needs building", "Uploaded", "building_soon"),
24 ("Successfully built", "Unscheduled", "wont_release"),
25 ("Successfully built", "Pending", "releasing_soon"),
26 ("Successfully built", "Failed to upload", "release_failed"),
27 (
28 "Successfully built",
29 "Failed to release to channels",
30 "release_failed",
31 ),
32 ("Successfully built", "Uploaded", "released"),
33 ("Currently building", "Unscheduled", "in_progress"),
34 ("Currently building", "Pending", "in_progress"),
35 ("Currently building", "Failed to upload", "in_progress"),
36 (
37 "Currently building",
38 "Failed to release to channels",
39 "in_progress",
40 ),
41 ("Currently building", "Uploaded", "in_progress"),
42 ("Gathering build output", "Unscheduled", "in_progress"),
43 ("Gathering build output", "Pending", "in_progress"),
44 ("Gathering build output", "Failed to upload", "in_progress"),
45 (
46 "Gathering build output",
47 "Failed to release to channels",
48 "in_progress",
49 ),
50 ("Gathering build output", "Uploaded", "in_progress"),
51 ("Failed to build", "Unscheduled", "failed_to_build"),
52 ("Failed to build", "Pending", "failed_to_build"),
53 ("Failed to build", "Failed to upload", "failed_to_build"),
54 (
55 "Failed to build",
56 "Failed to release to channels",
57 "failed_to_build",
58 ),
59 ("Failed to build", "Uploaded", "failed_to_build"),
60 ("Dependency wait", "Unscheduled", "failed_to_build"),
61 ("Dependency wait", "Pending", "failed_to_build"),
62 ("Dependency wait", "Failed to upload", "failed_to_build"),
63 (
64 "Dependency wait",
65 "Failed to release to channels",
66 "failed_to_build",
67 ),
68 ("Dependency wait", "Uploaded", "failed_to_build"),
69 ("Chroot problem", "Unscheduled", "failed_to_build"),
70 ("Chroot problem", "Pending", "failed_to_build"),
71 ("Chroot problem", "Failed to upload", "failed_to_build"),
72 (
73 "Chroot problem",
74 "Failed to release to channels",
75 "failed_to_build",
76 ),
77 ("Chroot problem", "Uploaded", "failed_to_build"),
78 ("Build for superseded Source", "Unscheduled", "failed_to_build"),
79 ("Build for superseded Source", "Pending", "failed_to_build"),
80 (
81 "Build for superseded Source",
82 "Failed to upload",
83 "failed_to_build",
84 ),
85 (
86 "Build for superseded Source",
87 "Failed to release to channels",
88 "failed_to_build",
89 ),
90 ("Build for superseded Source", "Uploaded", "failed_to_build"),
91 ("Failed to upload", "Unscheduled", "failed_to_build"),
92 ("Failed to upload", "Pending", "failed_to_build"),
93 ("Failed to upload", "Failed to upload", "failed_to_build"),
94 (
95 "Failed to upload",
96 "Failed to release to channels",
97 "failed_to_build",
98 ),
99 ("Failed to upload", "Uploaded", "failed_to_build"),
100 ("Cancelling build", "Unscheduled", "cancelled"),
101 ("Cancelling build", "Pending", "cancelled"),
102 ("Cancelling build", "Cancelling build", "cancelled"),
103 (
104 "Cancelling build",
105 "Failed to release to channels",
106 "cancelled",
107 ),
108 ("Cancelling build", "Uploaded", "cancelled"),
109 ("Cancelled build", "Unscheduled", "cancelled"),
110 ("Cancelled build", "Pending", "cancelled"),
111 ("Cancelled build", "Cancelled build", "cancelled"),
112 (
113 "Cancelled build",
114 "Failed to release to channels",
115 "cancelled",
116 ),
117 ("Cancelled build", "Uploaded", "cancelled"),
118 ("Failed to upload", "Unscheduled", "failed_to_build"),
119 ("Failed to upload", "Pending", "failed_to_build"),
120 ("Failed to upload", "Failed to upload", "failed_to_build"),
121 (
122 "Failed to upload",
123 "Failed to release to channels",
124 "failed_to_build",
125 ),
126 ("Failed to upload", "Uploaded", "failed_to_build"),
127 ]
129 for build_state, upload_state, expected in combinations:
130 result = map_build_and_upload_states(build_state, upload_state)
131 self.assertEqual(result, expected)
134class TestGetBuilds(unittest.TestCase):
135 @patch("webapp.publisher.snaps.build_views.launchpad")
136 def test_get_builds_includes_github_repository(self, mock_launchpad):
137 """Test that get_builds includes GitHub repository information"""
138 # Mock Launchpad snap data with GitHub repository URL
139 lp_snap = {
140 "store_name": "test-snap",
141 "git_repository_url": "https://github.com/owner/repo",
142 }
144 # Mock build data from Launchpad
145 mock_builds = [
146 {
147 "self_link": (
148 "https://api.launchpad.net/devel/~owner/"
149 "+snap/test-snap/+build/123"
150 ),
151 "arch_tag": "amd64",
152 "datebuilt": "2023-01-01T12:00:00Z",
153 "duration": "00:05:30",
154 "build_log_url": (
155 "https://launchpad.net/~owner/+snap/test-snap/"
156 "+build/123/+files/buildlog.txt"
157 ),
158 "revision_id": "abcdef1234567890abcdef1234567890abcdef12",
159 "buildstate": "Successfully built",
160 "store_upload_status": "Uploaded",
161 "title": "Test build",
162 }
163 ]
165 mock_launchpad.get_snap_builds.return_value = mock_builds
167 # Call get_builds
168 result = get_builds(lp_snap, slice(0, 10))
170 # Verify the result includes GitHub repository information
171 self.assertEqual(result["total_builds"], 1)
172 self.assertEqual(len(result["snap_builds"]), 1)
174 build = result["snap_builds"][0]
175 self.assertEqual(build["id"], "123")
176 self.assertEqual(build["arch_tag"], "amd64")
177 self.assertEqual(
178 build["revision_id"], "abcdef1234567890abcdef1234567890abcdef12"
179 )
180 self.assertEqual(build["github_repository"], "owner/repo")
181 self.assertEqual(build["status"], "released")
183 @patch("webapp.publisher.snaps.build_views.launchpad")
184 def test_get_builds_without_github_repository(self, mock_launchpad):
185 """Test that get_builds handles snaps without GitHub repository"""
186 # Mock Launchpad snap data without GitHub repository URL
187 lp_snap = {"store_name": "test-snap"}
189 # Mock build data from Launchpad
190 mock_builds = [
191 {
192 "self_link": (
193 "https://api.launchpad.net/devel/~owner/"
194 "+snap/test-snap/+build/123"
195 ),
196 "arch_tag": "amd64",
197 "datebuilt": "2023-01-01T12:00:00Z",
198 "duration": "00:05:30",
199 "build_log_url": (
200 "https://launchpad.net/~owner/+snap/test-snap/"
201 "+build/123/+files/buildlog.txt"
202 ),
203 "revision_id": "abcdef1234567890abcdef1234567890abcdef12",
204 "buildstate": "Successfully built",
205 "store_upload_status": "Uploaded",
206 "title": "Test build",
207 }
208 ]
210 mock_launchpad.get_snap_builds.return_value = mock_builds
212 # Call get_builds
213 result = get_builds(lp_snap, slice(0, 10))
215 # Verify the result has None for GitHub repository
216 build = result["snap_builds"][0]
217 self.assertIsNone(build["github_repository"])
220class TestExtractGithubRepository(unittest.TestCase):
221 """Test the extract_github_repository helper function."""
223 def test_extract_valid_github_url(self):
224 """Test extracting owner/repo from valid GitHub URLs."""
225 test_cases = [
226 ("https://github.com/owner/repo", "owner/repo"),
227 ("https://github.com/owner/repo.git", "owner/repo"),
228 ("https://github.com/owner/repo/", "owner/repo"),
229 ("https://github.com/owner/repo.git/", "owner/repo"),
230 ("http://github.com/owner/repo", "owner/repo"),
231 ]
233 for url, expected in test_cases:
234 with self.subTest(url=url):
235 result = extract_github_repository(url)
236 self.assertEqual(result, expected)
238 def test_extract_invalid_urls(self):
239 """Test that invalid URLs return None."""
240 test_cases = [
241 None,
242 "",
243 "https://gitlab.com/owner/repo",
244 "https://bitbucket.org/owner/repo",
245 "not-a-url",
246 "https://github.com/",
247 "https://github.com/owner",
248 ]
250 for url in test_cases:
251 with self.subTest(url=url):
252 result = extract_github_repository(url)
253 self.assertIsNone(result)
256class TestPostSnapBuilds(unittest.TestCase):
257 """
258 Test that post_snap_builds correctly passes the discharge macaroon
259 through to Launchpad, and re-runs the store authorization handshake
260 when a repository is already linked, so builds don't silently get
261 stuck as "Won't release".
262 """
264 def setUp(self):
265 self.app = create_app(testing=True)
266 self.app.secret_key = "secret_key"
267 self.app.config["WTF_CSRF_METHODS"] = []
268 self.client = self.app.test_client()
270 self.snap_name = "test-snap"
271 self.endpoint_url = f"/api/{self.snap_name}/builds"
273 # Stub the blueprint-level "has releases" gate so tests don't hit
274 # the real dashboard API.
275 release_history_patcher = patch(
276 "webapp.decorators._dashboard.snap_release_history",
277 return_value={"revisions": [{"revision": 1}]},
278 )
279 release_history_patcher.start()
280 self.addCleanup(release_history_patcher.stop)
282 with self.client.session_transaction() as session:
283 session["publisher"] = {
284 "image": None,
285 "nickname": "Toto",
286 "fullname": "El Toto",
287 "email": "testing@testing.com",
288 "stores": [],
289 }
290 session["macaroon_exchanged"] = "test-exchanged-macaroon"
291 session["macaroon_discharge"] = "test-discharge-macaroon"
292 session["github_auth_secret"] = "test-github-token"
294 def _mock_dashboard(self, mock_dashboard):
295 mock_dashboard.get_snap_info.return_value = {
296 "snap_name": self.snap_name
297 }
298 mock_dashboard.get_account_snaps.return_value = {self.snap_name: {}}
299 mock_dashboard.get_package_upload_macaroon.return_value = {
300 "macaroon": "test-upload-macaroon"
301 }
303 def _mock_github(self, mock_github_class):
304 mock_github = MagicMock()
305 mock_github.check_permissions_over_repo.return_value = True
306 mock_github.get_hook_by_url.return_value = None
307 mock_github_class.return_value = mock_github
308 return mock_github
310 @patch("webapp.publisher.snaps.build_views.validate_repo")
311 @patch("webapp.publisher.snaps.build_views.GitHub")
312 @patch("webapp.publisher.snaps.build_views.launchpad")
313 @patch("webapp.publisher.snaps.build_views.dashboard")
314 def test_new_link_passes_discharge_macaroon_to_create_snap(
315 self,
316 mock_dashboard,
317 mock_launchpad,
318 mock_github_class,
319 mock_validate_repo,
320 ):
321 self._mock_dashboard(mock_dashboard)
322 self._mock_github(mock_github_class)
323 mock_validate_repo.return_value = {"success": True}
325 # No existing snap linked in Launchpad yet.
326 mock_launchpad.get_snap_by_store_name.return_value = None
327 mock_launchpad.get_snap.return_value = False
329 response = self.client.post(
330 self.endpoint_url,
331 data={"github_repository": "owner/repo"},
332 )
334 self.assertEqual(response.status_code, 200)
335 self.assertEqual(
336 response.get_json(),
337 {
338 "success": False,
339 "authorization_required": True,
340 "redirect_url": "/login/snap-build-authorization",
341 },
342 )
343 with self.client.session_transaction() as session:
344 self.assertEqual(
345 session["pending_snap_authorization"],
346 {
347 "action": "link",
348 "snap_name": self.snap_name,
349 "git_url": "https://github.com/owner/repo",
350 "owner": "owner",
351 "repo": "repo",
352 "lp_snap_name": None,
353 "root_macaroon": "test-upload-macaroon",
354 "redirect_url": f"/{self.snap_name}/builds",
355 },
356 )
358 mock_launchpad.create_snap.assert_not_called()
359 mock_launchpad.complete_snap_authorization.assert_not_called()
361 @patch("webapp.publisher.snaps.build_views.validate_repo")
362 @patch("webapp.publisher.snaps.build_views.GitHub")
363 @patch("webapp.publisher.snaps.build_views.launchpad")
364 @patch("webapp.publisher.snaps.build_views.dashboard")
365 def test_new_link_without_discharge_in_session(
366 self,
367 mock_dashboard,
368 mock_launchpad,
369 mock_github_class,
370 mock_validate_repo,
371 ):
372 """Backward compat: no discharge in session -> None is passed."""
373 self._mock_dashboard(mock_dashboard)
374 self._mock_github(mock_github_class)
375 mock_validate_repo.return_value = {"success": True}
377 mock_launchpad.get_snap_by_store_name.return_value = None
378 mock_launchpad.get_snap.return_value = False
380 with self.client.session_transaction() as session:
381 del session["macaroon_discharge"]
383 response = self.client.post(
384 self.endpoint_url,
385 data={"github_repository": "owner/repo"},
386 )
388 self.assertEqual(response.status_code, 200)
389 self.assertEqual(
390 response.get_json(),
391 {
392 "success": False,
393 "authorization_required": True,
394 "redirect_url": "/login/snap-build-authorization",
395 },
396 )
397 with self.client.session_transaction() as session:
398 self.assertEqual(
399 session["pending_snap_authorization"]["action"], "link"
400 )
401 self.assertEqual(
402 session["pending_snap_authorization"]["snap_name"],
403 self.snap_name,
404 )
405 self.assertEqual(
406 session["pending_snap_authorization"]["git_url"],
407 "https://github.com/owner/repo",
408 )
409 self.assertEqual(
410 session["pending_snap_authorization"]["root_macaroon"],
411 "test-upload-macaroon",
412 )
414 mock_launchpad.create_snap.assert_not_called()
416 @patch("webapp.publisher.snaps.build_views.validate_repo")
417 @patch("webapp.publisher.snaps.build_views.GitHub")
418 @patch("webapp.publisher.snaps.build_views.launchpad")
419 @patch("webapp.publisher.snaps.build_views.dashboard")
420 def test_existing_link_reauthorizes_instead_of_noop(
421 self,
422 mock_dashboard,
423 mock_launchpad,
424 mock_github_class,
425 mock_validate_repo,
426 ):
427 """
428 Reconnecting an already-linked repository must re-run the store
429 authorization handshake (previously a silent no-op), so users can
430 self-repair snaps stuck as "Won't release".
431 """
432 self._mock_dashboard(mock_dashboard)
433 self._mock_github(mock_github_class)
434 mock_validate_repo.return_value = {"success": True}
436 mock_launchpad.get_snap_by_store_name.return_value = {
437 "name": "lp-snap-name",
438 "git_repository_url": "https://github.com/owner/repo",
439 }
441 response = self.client.post(
442 self.endpoint_url,
443 data={"github_repository": "owner/repo"},
444 )
446 self.assertEqual(response.status_code, 200)
447 self.assertEqual(
448 response.get_json(),
449 {
450 "success": False,
451 "authorization_required": True,
452 "redirect_url": "/login/snap-build-authorization",
453 },
454 )
455 with self.client.session_transaction() as session:
456 self.assertEqual(
457 session["pending_snap_authorization"],
458 {
459 "action": "repair",
460 "snap_name": self.snap_name,
461 "git_url": "https://github.com/owner/repo",
462 "owner": "owner",
463 "repo": "repo",
464 "lp_snap_name": "lp-snap-name",
465 "root_macaroon": "test-upload-macaroon",
466 "redirect_url": f"/{self.snap_name}/builds",
467 },
468 )
470 mock_launchpad.complete_snap_authorization.assert_not_called()
471 mock_launchpad.create_snap.assert_not_called()
473 @patch("webapp.publisher.snaps.build_views.validate_repo")
474 @patch("webapp.publisher.snaps.build_views.GitHub")
475 @patch("webapp.publisher.snaps.build_views.launchpad")
476 @patch("webapp.publisher.snaps.build_views.dashboard")
477 def test_mismatched_repo_raises(
478 self,
479 mock_dashboard,
480 mock_launchpad,
481 mock_github_class,
482 mock_validate_repo,
483 ):
484 """A snap already linked to a different repo should still error."""
485 self._mock_dashboard(mock_dashboard)
486 self._mock_github(mock_github_class)
487 mock_validate_repo.return_value = {"success": True}
489 mock_launchpad.get_snap_by_store_name.return_value = {
490 "name": "lp-snap-name",
491 "git_repository_url": "https://github.com/owner/other-repo",
492 }
494 try:
495 response = self.client.post(
496 self.endpoint_url,
497 data={"github_repository": "owner/repo"},
498 )
499 except AttributeError:
500 response = None
502 if response is not None:
503 self.assertEqual(response.status_code, 500)
505 mock_launchpad.complete_snap_authorization.assert_not_called()
506 mock_launchpad.create_snap.assert_not_called()