Coverage for tests/endpoints/tests_snaps_auditable.py: 100%

206 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-20 22:09 +0000

1from unittest.mock import patch 

2 

3from tests.endpoints.endpoint_testing import TestEndpoints 

4from webapp.endpoints.snaps import ( 

5 FAILED_PROVENANCE_TTL, 

6 _get_provenance_map, 

7) 

8 

9 

10def _channel(arch, track, risk, revision): 

11 return { 

12 "channel": { 

13 "architecture": arch, 

14 "track": track, 

15 "risk": risk, 

16 "name": f"{track}/{risk}", 

17 "released-at": "2024-03-28T05:16:07.500510+00:00", 

18 }, 

19 "revision": revision, 

20 "version": "1.0", 

21 "confinement": "strict", 

22 "download": {"size": 100}, 

23 } 

24 

25 

26def _details(channel_map, default_track="latest"): 

27 return { 

28 "channel-map": channel_map, 

29 "default-track": default_track, 

30 "snap": {}, 

31 } 

32 

33 

34def _provenance( 

35 revisions, 

36 github_repository="snapcrafters/mumble", 

37 failed=False, 

38): 

39 return { 

40 "github_repository": github_repository, 

41 "git_repository_url": "https://github.com/snapcrafters/mumble", 

42 "revisions": revisions, 

43 "failed": failed, 

44 } 

45 

46 

47VERIFIED_BUILD = { 

48 "commit_sha": "10c7c9e1234567890", 

49 "commit_url": ( 

50 "https://github.com/snapcrafters/mumble/commit/10c7c9e1234567890" 

51 ), 

52 "build_id": "216436", 

53 "build_url": ( 

54 "https://launchpad.net/~build.snapcraft.io/+snap/x/+build/216436" 

55 ), 

56} 

57 

58 

59class TestProvenanceMapCaching(TestEndpoints): 

60 """What gets cached decides whether a Launchpad wobble becomes a storm.""" 

61 

62 def setUp(self): 

63 super().setUp() 

64 cache_patcher = patch("webapp.endpoints.snaps.redis_cache") 

65 self.cache_patch = cache_patcher.start() 

66 self.cache_patch.get.return_value = None 

67 self.addCleanup(cache_patcher.stop) 

68 

69 def _ttl_for(self, provenance_map): 

70 with patch( 

71 "webapp.endpoints.snaps.launchpad_provenance." 

72 "build_provenance_map", 

73 return_value=provenance_map, 

74 ): 

75 _get_provenance_map("mumble") 

76 return self.cache_patch.set.call_args.kwargs["ttl"] 

77 

78 def test_successful_scan_is_cached_for_an_hour(self): 

79 # Includes scans that stopped at their bounds: partial but valid. 

80 self.assertEqual(self._ttl_for(_provenance({})), 3600) 

81 

82 def test_failed_scan_is_cached_briefly(self): 

83 ttl = self._ttl_for(_provenance({}, failed=True)) 

84 self.assertEqual(ttl, FAILED_PROVENANCE_TTL) 

85 self.assertLess(ttl, 3600) 

86 

87 @patch("webapp.endpoints.snaps.repository_is_public") 

88 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map") 

89 def test_repository_is_checked_once_and_cached( 

90 self, mock_map, mock_public 

91 ): 

92 # One call per map build, not one per page view. 

93 mock_map.return_value = _provenance({}) 

94 mock_public.return_value = False 

95 

96 result = _get_provenance_map("mumble") 

97 

98 mock_public.assert_called_once_with("snapcrafters/mumble") 

99 self.assertFalse(result["source_available"]) 

100 cached = self.cache_patch.set.call_args.args[1] 

101 self.assertFalse(cached["source_available"]) 

102 

103 

104class TestAuditableEndpoint(TestEndpoints): 

105 def setUp(self): 

106 super().setUp() 

107 # Force cache miss so build_provenance_map is always exercised. 

108 cache_patcher = patch("webapp.endpoints.snaps.redis_cache") 

109 self.cache_patch = cache_patcher.start() 

110 self.cache_patch.get.return_value = None 

111 self.addCleanup(cache_patcher.stop) 

112 # Real HTTP; keep it off the network. 

113 public_patcher = patch( 

114 "webapp.endpoints.snaps.repository_is_public", return_value=True 

115 ) 

116 self.public_patch = public_patcher.start() 

117 self.addCleanup(public_patcher.stop) 

118 

119 @patch("webapp.endpoints.snaps.device_gateway.get_item_details") 

120 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map") 

121 def test_auditable_hit(self, mock_map, mock_details): 

122 mock_details.return_value = _details( 

123 [_channel("amd64", "latest", "stable", 1721)] 

124 ) 

125 mock_map.return_value = _provenance( 

126 {"1721": {"amd64": VERIFIED_BUILD}} 

127 ) 

128 

129 response = self.app.test_client().get("/api/mumble/auditable") 

130 data = response.get_json() 

131 

132 self.assertEqual(response.status_code, 200) 

133 self.assertTrue(data["auditable"]) 

134 self.assertEqual(data["status"], "verified") 

135 self.assertEqual(data["revision"], 1721) 

136 self.assertEqual(data["architecture"], "amd64") 

137 self.assertEqual(data["commit_sha"], "10c7c9e1234567890") 

138 self.assertEqual(data["github_repository"], "snapcrafters/mumble") 

139 self.assertIn("commit/10c7c9e", data["commit_url"]) 

140 self.assertEqual(data["build_id"], "216436") 

141 self.assertEqual(response.cache_control.max_age, 3600) 

142 

143 @patch("webapp.endpoints.snaps.device_gateway.get_item_details") 

144 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map") 

145 def test_prefers_amd64(self, mock_map, mock_details): 

146 mock_details.return_value = _details( 

147 [ 

148 _channel("arm64", "latest", "stable", 1798), 

149 _channel("amd64", "latest", "stable", 1721), 

150 ] 

151 ) 

152 mock_map.return_value = _provenance( 

153 { 

154 "1721": {"amd64": VERIFIED_BUILD}, 

155 "1798": {"arm64": VERIFIED_BUILD}, 

156 } 

157 ) 

158 

159 data = self.client.get("/api/mumble/auditable").get_json() 

160 

161 self.assertTrue(data["auditable"]) 

162 self.assertEqual(data["architecture"], "amd64") 

163 self.assertEqual(data["revision"], 1721) 

164 

165 @patch("webapp.endpoints.snaps.device_gateway.get_item_details") 

166 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map") 

167 def test_deterministic_arch_fallback(self, mock_map, mock_details): 

168 # No amd64 -> first architecture sorted alphabetically (arm64). 

169 mock_details.return_value = _details( 

170 [ 

171 _channel("armhf", "latest", "stable", 900), 

172 _channel("arm64", "latest", "stable", 800), 

173 ] 

174 ) 

175 mock_map.return_value = _provenance( 

176 { 

177 "800": {"arm64": VERIFIED_BUILD}, 

178 "900": {"armhf": VERIFIED_BUILD}, 

179 } 

180 ) 

181 

182 data = self.client.get("/api/mumble/auditable").get_json() 

183 

184 self.assertTrue(data["auditable"]) 

185 self.assertEqual(data["architecture"], "arm64") 

186 self.assertEqual(data["revision"], 800) 

187 

188 @patch("webapp.endpoints.snaps.device_gateway.get_item_details") 

189 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map") 

190 def test_unavailable_when_revision_absent(self, mock_map, mock_details): 

191 # Public recipe exists (github_repository set) but this revision has no 

192 # matching build -> "unavailable", not "not-provided". 

193 mock_details.return_value = _details( 

194 [_channel("amd64", "latest", "stable", 1721)] 

195 ) 

196 mock_map.return_value = _provenance({}) 

197 

198 data = self.client.get("/api/mumble/auditable").get_json() 

199 

200 self.assertFalse(data["auditable"]) 

201 self.assertEqual(data["status"], "unavailable") 

202 self.assertEqual(data["revision"], 1721) 

203 self.assertEqual(data["architecture"], "amd64") 

204 

205 @patch("webapp.endpoints.snaps.device_gateway.get_item_details") 

206 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map") 

207 def test_not_provided_when_no_public_recipe(self, mock_map, mock_details): 

208 # No public GitHub recipe (private / non-GitHub) -> "not-provided". 

209 mock_details.return_value = _details( 

210 [_channel("amd64", "latest", "stable", 1721)] 

211 ) 

212 mock_map.return_value = _provenance({}, github_repository=None) 

213 

214 data = self.client.get("/api/mumble/auditable").get_json() 

215 

216 self.assertFalse(data["auditable"]) 

217 self.assertEqual(data["status"], "not-provided") 

218 self.assertEqual(data["revision"], 1721) 

219 

220 @patch("webapp.endpoints.snaps.device_gateway.get_item_details") 

221 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map") 

222 def test_non_github_repo_is_not_provided(self, mock_map, mock_details): 

223 mock_details.return_value = _details( 

224 [_channel("amd64", "latest", "stable", 1721)] 

225 ) 

226 # Non-GitHub build: commit_url is None and no github repository. 

227 no_link_build = dict(VERIFIED_BUILD, commit_url=None) 

228 mock_map.return_value = _provenance( 

229 {"1721": {"amd64": no_link_build}}, github_repository=None 

230 ) 

231 

232 data = self.client.get("/api/mumble/auditable").get_json() 

233 

234 self.assertFalse(data["auditable"]) 

235 self.assertEqual(data["status"], "not-provided") 

236 

237 @patch("webapp.endpoints.snaps.device_gateway.get_item_details") 

238 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map") 

239 def test_upstream_failure_reports_error(self, mock_map, mock_details): 

240 mock_details.return_value = _details( 

241 [_channel("amd64", "latest", "stable", 1721)] 

242 ) 

243 mock_map.side_effect = Exception("launchpad down") 

244 

245 response = self.app.test_client().get("/api/mumble/auditable") 

246 

247 self.assertEqual(response.status_code, 200) 

248 self.assertEqual( 

249 response.get_json(), {"auditable": False, "status": "error"} 

250 ) 

251 self.assertEqual(response.cache_control.max_age, 0) 

252 

253 @patch("webapp.endpoints.snaps.device_gateway.get_item_details") 

254 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map") 

255 def test_failed_scan_reports_error(self, mock_map, mock_details): 

256 # The one case where retrying might help. 

257 mock_details.return_value = _details( 

258 [_channel("amd64", "latest", "stable", 1721)] 

259 ) 

260 mock_map.return_value = _provenance({}, failed=True) 

261 

262 data = self.client.get("/api/mumble/auditable").get_json() 

263 

264 self.assertFalse(data["auditable"]) 

265 self.assertEqual(data["status"], "error") 

266 self.assertEqual(data["revision"], 1721) 

267 self.assertEqual(data["architecture"], "amd64") 

268 

269 @patch("webapp.endpoints.snaps.device_gateway.get_item_details") 

270 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map") 

271 def test_bounded_scan_is_not_an_error(self, mock_map, mock_details): 

272 # Hitting the scan bounds must not pin an error on a snap forever. 

273 mock_details.return_value = _details( 

274 [_channel("amd64", "latest", "stable", 1721)] 

275 ) 

276 mock_map.return_value = _provenance({}, github_repository=None) 

277 

278 data = self.client.get("/api/mumble/auditable").get_json() 

279 

280 self.assertFalse(data["auditable"]) 

281 self.assertEqual(data["status"], "not-provided") 

282 

283 @patch("webapp.endpoints.snaps.device_gateway.get_item_details") 

284 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map") 

285 def test_deleted_repository_is_not_verified(self, mock_map, mock_details): 

286 # The commit link would 404, so offering it is worse than nothing. 

287 mock_details.return_value = _details( 

288 [_channel("amd64", "latest", "stable", 1721)] 

289 ) 

290 mock_map.return_value = _provenance( 

291 {"1721": {"amd64": VERIFIED_BUILD}} 

292 ) 

293 self.public_patch.return_value = False 

294 

295 data = self.client.get("/api/mumble/auditable").get_json() 

296 

297 self.assertFalse(data["auditable"]) 

298 self.assertEqual(data["status"], "not-provided") 

299 self.assertNotIn("commit_url", data) 

300 

301 @patch("webapp.endpoints.snaps.device_gateway.get_item_details") 

302 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map") 

303 def test_build_without_commit_url_is_not_an_error( 

304 self, mock_map, mock_details 

305 ): 

306 # Build found, but no GitHub commit to link: settled, not a failure. 

307 mock_details.return_value = _details( 

308 [_channel("amd64", "latest", "stable", 1721)] 

309 ) 

310 mock_map.return_value = _provenance( 

311 {"1721": {"amd64": {"commit_sha": "aaa", "commit_url": None}}}, 

312 github_repository=None, 

313 ) 

314 

315 data = self.client.get("/api/mumble/auditable").get_json() 

316 

317 self.assertFalse(data["auditable"]) 

318 self.assertEqual(data["status"], "not-provided") 

319 

320 @patch("webapp.endpoints.snaps.device_gateway.get_item_details") 

321 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map") 

322 def test_hit_in_truncated_scan_is_still_verified( 

323 self, mock_map, mock_details 

324 ): 

325 # A found revision is authoritative however many pages went unread. 

326 mock_details.return_value = _details( 

327 [_channel("amd64", "latest", "stable", 1721)] 

328 ) 

329 mock_map.return_value = _provenance( 

330 {"1721": {"amd64": VERIFIED_BUILD}} 

331 ) 

332 

333 data = self.client.get("/api/mumble/auditable").get_json() 

334 

335 self.assertTrue(data["auditable"]) 

336 self.assertEqual(data["status"], "verified") 

337 

338 

339class TestAuditableRevisionsEndpoint(TestEndpoints): 

340 def setUp(self): 

341 super().setUp() 

342 self.cache_patch = patch("webapp.endpoints.snaps.redis_cache").start() 

343 self.cache_patch.get.return_value = None 

344 # Real HTTP; keep it off the network. 

345 self.public_patch = patch( 

346 "webapp.endpoints.snaps.repository_is_public", return_value=True 

347 ).start() 

348 self.addCleanup(patch.stopall) 

349 

350 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map") 

351 def test_returns_flattened_revisions(self, mock_map): 

352 mock_map.return_value = _provenance( 

353 { 

354 "1721": {"amd64": VERIFIED_BUILD}, 

355 "1798": {"arm64": VERIFIED_BUILD}, 

356 } 

357 ) 

358 

359 data = self.client.get("/api/mumble/auditable-revisions").get_json() 

360 

361 self.assertEqual(data["github_repository"], "snapcrafters/mumble") 

362 self.assertIn("1721", data["revisions"]) 

363 self.assertIn("1798", data["revisions"]) 

364 self.assertEqual( 

365 data["revisions"]["1721"]["commit_sha"], "10c7c9e1234567890" 

366 ) 

367 

368 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map") 

369 def test_revisions_failure_reports_error(self, mock_map): 

370 mock_map.side_effect = Exception("launchpad down") 

371 

372 response = self.app.test_client().get( 

373 "/api/mumble/auditable-revisions" 

374 ) 

375 

376 self.assertEqual(response.status_code, 200) 

377 self.assertEqual( 

378 response.get_json(), 

379 {"github_repository": None, "revisions": {}, "error": True}, 

380 ) 

381 self.assertEqual(response.cache_control.max_age, 0) 

382 

383 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map") 

384 def test_revisions_omitted_when_repository_is_gone(self, mock_map): 

385 mock_map.return_value = _provenance( 

386 {"1721": {"amd64": VERIFIED_BUILD}} 

387 ) 

388 self.public_patch.return_value = False 

389 

390 data = self.client.get("/api/mumble/auditable-revisions").get_json() 

391 

392 self.assertEqual(data["revisions"], {}) 

393 self.assertFalse(data["error"]) 

394 

395 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map") 

396 def test_revisions_failed_scan_sets_error_flag(self, mock_map): 

397 mock_map.return_value = _provenance( 

398 {"1721": {"amd64": VERIFIED_BUILD}}, failed=True 

399 ) 

400 

401 data = self.client.get("/api/mumble/auditable-revisions").get_json() 

402 

403 self.assertTrue(data["error"]) 

404 # Partial data is still returned. 

405 self.assertIn("1721", data["revisions"]) 

406 

407 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map") 

408 def test_revisions_truncated_scan_is_not_an_error(self, mock_map): 

409 # Bounded by LP_MAX_BUILD_PAGES by design, so not a failure. 

410 mock_map.return_value = _provenance( 

411 {"1721": {"amd64": VERIFIED_BUILD}}, failed=False 

412 ) 

413 

414 # Logged-in responses are rewritten to `private`, dropping max-age. 

415 response = self.app.test_client().get( 

416 "/api/mumble/auditable-revisions" 

417 ) 

418 data = response.get_json() 

419 

420 self.assertFalse(data["error"]) 

421 self.assertIn("1721", data["revisions"]) 

422 self.assertEqual(response.cache_control.max_age, 3600)