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

1import unittest 

2from unittest.mock import patch, MagicMock 

3 

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) 

10 

11 

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 ] 

128 

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) 

132 

133 

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 } 

143 

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 ] 

164 

165 mock_launchpad.get_snap_builds.return_value = mock_builds 

166 

167 # Call get_builds 

168 result = get_builds(lp_snap, slice(0, 10)) 

169 

170 # Verify the result includes GitHub repository information 

171 self.assertEqual(result["total_builds"], 1) 

172 self.assertEqual(len(result["snap_builds"]), 1) 

173 

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") 

182 

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"} 

188 

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 ] 

209 

210 mock_launchpad.get_snap_builds.return_value = mock_builds 

211 

212 # Call get_builds 

213 result = get_builds(lp_snap, slice(0, 10)) 

214 

215 # Verify the result has None for GitHub repository 

216 build = result["snap_builds"][0] 

217 self.assertIsNone(build["github_repository"]) 

218 

219 

220class TestExtractGithubRepository(unittest.TestCase): 

221 """Test the extract_github_repository helper function.""" 

222 

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 ] 

232 

233 for url, expected in test_cases: 

234 with self.subTest(url=url): 

235 result = extract_github_repository(url) 

236 self.assertEqual(result, expected) 

237 

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 ] 

249 

250 for url in test_cases: 

251 with self.subTest(url=url): 

252 result = extract_github_repository(url) 

253 self.assertIsNone(result) 

254 

255 

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 """ 

263 

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() 

269 

270 self.snap_name = "test-snap" 

271 self.endpoint_url = f"/api/{self.snap_name}/builds" 

272 

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) 

281 

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" 

293 

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 } 

302 

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 

309 

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} 

324 

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 

328 

329 response = self.client.post( 

330 self.endpoint_url, 

331 data={"github_repository": "owner/repo"}, 

332 ) 

333 

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 ) 

357 

358 mock_launchpad.create_snap.assert_not_called() 

359 mock_launchpad.complete_snap_authorization.assert_not_called() 

360 

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} 

376 

377 mock_launchpad.get_snap_by_store_name.return_value = None 

378 mock_launchpad.get_snap.return_value = False 

379 

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

381 del session["macaroon_discharge"] 

382 

383 response = self.client.post( 

384 self.endpoint_url, 

385 data={"github_repository": "owner/repo"}, 

386 ) 

387 

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 ) 

413 

414 mock_launchpad.create_snap.assert_not_called() 

415 

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} 

435 

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 } 

440 

441 response = self.client.post( 

442 self.endpoint_url, 

443 data={"github_repository": "owner/repo"}, 

444 ) 

445 

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 ) 

469 

470 mock_launchpad.complete_snap_authorization.assert_not_called() 

471 mock_launchpad.create_snap.assert_not_called() 

472 

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} 

488 

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 } 

493 

494 try: 

495 response = self.client.post( 

496 self.endpoint_url, 

497 data={"github_repository": "owner/repo"}, 

498 ) 

499 except AttributeError: 

500 response = None 

501 

502 if response is not None: 

503 self.assertEqual(response.status_code, 500) 

504 

505 mock_launchpad.complete_snap_authorization.assert_not_called() 

506 mock_launchpad.create_snap.assert_not_called()