Coverage for tests/endpoints/tests_snaps_auditable.py: 100%
268 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
1from unittest.mock import patch
3from tests.endpoints.endpoint_testing import TestEndpoints
4from webapp.api.exceptions import ApiTimeoutError
5from webapp.endpoints.snaps import (
6 FAILED_PROVENANCE_TTL,
7 _get_provenance_map,
8)
11def _channel(arch, track, risk, revision):
12 return {
13 "channel": {
14 "architecture": arch,
15 "track": track,
16 "risk": risk,
17 "name": f"{track}/{risk}",
18 "released-at": "2024-03-28T05:16:07.500510+00:00",
19 },
20 "revision": revision,
21 "version": "1.0",
22 "confinement": "strict",
23 "download": {"size": 100},
24 }
27def _details(channel_map, default_track="latest"):
28 return {
29 "channel-map": channel_map,
30 "default-track": default_track,
31 "snap": {},
32 }
35def _provenance(
36 revisions,
37 github_repository="snapcrafters/mumble",
38 failed=False,
39 reason=None,
40):
41 return {
42 "github_repository": github_repository,
43 "git_repository_url": "https://github.com/snapcrafters/mumble",
44 "revisions": revisions,
45 "failed": failed,
46 "reason": reason,
47 }
50VERIFIED_BUILD = {
51 "commit_sha": "10c7c9e1234567890",
52 "commit_url": (
53 "https://github.com/snapcrafters/mumble/commit/10c7c9e1234567890"
54 ),
55 "build_id": "216436",
56 "build_url": (
57 "https://launchpad.net/~build.snapcraft.io/+snap/x/+build/216436"
58 ),
59}
62class TestProvenanceMapCaching(TestEndpoints):
63 """What gets cached decides whether a Launchpad wobble becomes a storm."""
65 def setUp(self):
66 super().setUp()
67 cache_patcher = patch("webapp.endpoints.snaps.redis_cache")
68 self.cache_patch = cache_patcher.start()
69 self.cache_patch.get.return_value = None
70 self.addCleanup(cache_patcher.stop)
71 details_patcher = patch(
72 "webapp.endpoints.snaps.device_gateway.get_item_details",
73 return_value=_details(
74 [_channel("amd64", "latest", "stable", 1721)]
75 ),
76 )
77 details_patcher.start()
78 self.addCleanup(details_patcher.stop)
80 def _ttl_for(self, provenance_map):
81 with patch(
82 "webapp.endpoints.snaps.launchpad_provenance."
83 "build_provenance_map",
84 return_value=provenance_map,
85 ):
86 _get_provenance_map("mumble")
87 return self.cache_patch.set.call_args.kwargs["ttl"]
89 def test_successful_scan_is_cached_for_an_hour(self):
90 # Includes scans that stopped at their bounds: partial but valid.
91 self.assertEqual(self._ttl_for(_provenance({})), 3600)
93 def test_failed_scan_is_cached_briefly(self):
94 ttl = self._ttl_for(_provenance({}, failed=True))
95 self.assertEqual(ttl, FAILED_PROVENANCE_TTL)
96 self.assertLess(ttl, 3600)
98 @patch("webapp.endpoints.snaps.repository_is_public")
99 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
100 def test_repository_is_checked_once_and_cached(
101 self, mock_map, mock_public
102 ):
103 # One call per map build, not one per page view.
104 mock_map.return_value = _provenance({})
105 mock_public.return_value = False
107 result = _get_provenance_map("mumble")
109 mock_public.assert_called_once_with("snapcrafters/mumble")
110 self.assertFalse(result["source_available"])
111 cached = self.cache_patch.set.call_args.args[1]
112 self.assertFalse(cached["source_available"])
115class TestAuditableEndpoint(TestEndpoints):
116 def setUp(self):
117 super().setUp()
118 # Force cache miss so build_provenance_map is always exercised.
119 cache_patcher = patch("webapp.endpoints.snaps.redis_cache")
120 self.cache_patch = cache_patcher.start()
121 self.cache_patch.get.return_value = None
122 self.addCleanup(cache_patcher.stop)
123 # Real HTTP; keep it off the network.
124 public_patcher = patch(
125 "webapp.endpoints.snaps.repository_is_public", return_value=True
126 )
127 self.public_patch = public_patcher.start()
128 self.addCleanup(public_patcher.stop)
130 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
131 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
132 def test_scan_is_told_every_released_revision(
133 self, mock_map, mock_details
134 ):
135 mock_details.return_value = _details(
136 [
137 _channel("amd64", "latest", "stable", 1721),
138 _channel("amd64", "latest", "edge", 1800),
139 _channel("arm64", "latest", "stable", 1798),
140 ]
141 )
142 mock_map.return_value = _provenance({})
144 self.client.get("/api/mumble/auditable")
146 mock_details.assert_called_once()
147 self.assertEqual(
148 mock_map.call_args.kwargs["wanted"], {"1721", "1798", "1800"}
149 )
151 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
152 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
153 def test_auditable_hit(self, mock_map, mock_details):
154 mock_details.return_value = _details(
155 [_channel("amd64", "latest", "stable", 1721)]
156 )
157 mock_map.return_value = _provenance(
158 {"1721": {"amd64": VERIFIED_BUILD}}
159 )
161 response = self.app.test_client().get("/api/mumble/auditable")
162 data = response.get_json()
164 self.assertEqual(response.status_code, 200)
165 self.assertTrue(data["auditable"])
166 self.assertEqual(data["status"], "verified")
167 self.assertEqual(data["revision"], 1721)
168 self.assertEqual(data["architecture"], "amd64")
169 self.assertEqual(data["commit_sha"], "10c7c9e1234567890")
170 self.assertEqual(data["github_repository"], "snapcrafters/mumble")
171 self.assertIn("commit/10c7c9e", data["commit_url"])
172 self.assertEqual(data["build_id"], "216436")
173 self.assertEqual(response.cache_control.max_age, 3600)
175 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
176 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
177 def test_prefers_amd64(self, mock_map, mock_details):
178 mock_details.return_value = _details(
179 [
180 _channel("arm64", "latest", "stable", 1798),
181 _channel("amd64", "latest", "stable", 1721),
182 ]
183 )
184 mock_map.return_value = _provenance(
185 {
186 "1721": {"amd64": VERIFIED_BUILD},
187 "1798": {"arm64": VERIFIED_BUILD},
188 }
189 )
191 data = self.client.get("/api/mumble/auditable").get_json()
193 self.assertTrue(data["auditable"])
194 self.assertEqual(data["architecture"], "amd64")
195 self.assertEqual(data["revision"], 1721)
197 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
198 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
199 def test_deterministic_arch_fallback(self, mock_map, mock_details):
200 # No amd64 -> first architecture sorted alphabetically (arm64).
201 mock_details.return_value = _details(
202 [
203 _channel("armhf", "latest", "stable", 900),
204 _channel("arm64", "latest", "stable", 800),
205 ]
206 )
207 mock_map.return_value = _provenance(
208 {
209 "800": {"arm64": VERIFIED_BUILD},
210 "900": {"armhf": VERIFIED_BUILD},
211 }
212 )
214 data = self.client.get("/api/mumble/auditable").get_json()
216 self.assertTrue(data["auditable"])
217 self.assertEqual(data["architecture"], "arm64")
218 self.assertEqual(data["revision"], 800)
220 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
221 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
222 def test_unavailable_when_revision_absent(self, mock_map, mock_details):
223 # Public recipe exists (github_repository set) but this revision has no
224 # matching build -> "unavailable", not "not-provided".
225 mock_details.return_value = _details(
226 [_channel("amd64", "latest", "stable", 1721)]
227 )
228 mock_map.return_value = _provenance({})
230 data = self.client.get("/api/mumble/auditable").get_json()
232 self.assertFalse(data["auditable"])
233 self.assertEqual(data["status"], "unavailable")
234 self.assertEqual(data["revision"], 1721)
235 self.assertEqual(data["architecture"], "amd64")
237 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
238 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
239 def test_not_provided_when_no_public_recipe(self, mock_map, mock_details):
240 # No public GitHub recipe (private / non-GitHub) -> "not-provided".
241 mock_details.return_value = _details(
242 [_channel("amd64", "latest", "stable", 1721)]
243 )
244 mock_map.return_value = _provenance({}, github_repository=None)
246 data = self.client.get("/api/mumble/auditable").get_json()
248 self.assertFalse(data["auditable"])
249 self.assertEqual(data["status"], "not-provided")
250 self.assertEqual(data["revision"], 1721)
252 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
253 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
254 def test_non_github_repo_is_not_provided(self, mock_map, mock_details):
255 mock_details.return_value = _details(
256 [_channel("amd64", "latest", "stable", 1721)]
257 )
258 # Non-GitHub build: commit_url is None and no github repository.
259 no_link_build = dict(VERIFIED_BUILD, commit_url=None)
260 mock_map.return_value = _provenance(
261 {"1721": {"amd64": no_link_build}}, github_repository=None
262 )
264 data = self.client.get("/api/mumble/auditable").get_json()
266 self.assertFalse(data["auditable"])
267 self.assertEqual(data["status"], "not-provided")
269 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
270 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
271 def test_upstream_failure_reports_error(self, mock_map, mock_details):
272 mock_details.return_value = _details(
273 [_channel("amd64", "latest", "stable", 1721)]
274 )
275 mock_map.side_effect = Exception("launchpad down")
277 response = self.app.test_client().get("/api/mumble/auditable")
279 self.assertEqual(response.status_code, 200)
280 self.assertEqual(
281 response.get_json(),
282 {
283 "auditable": False,
284 "status": "error",
285 "reason": "unexpected_error",
286 },
287 )
288 self.assertEqual(response.cache_control.max_age, FAILED_PROVENANCE_TTL)
290 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
291 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
292 def test_failed_scan_reports_error(self, mock_map, mock_details):
293 # The one case where retrying might help.
294 mock_details.return_value = _details(
295 [_channel("amd64", "latest", "stable", 1721)]
296 )
297 mock_map.return_value = _provenance({}, failed=True)
299 data = self.client.get("/api/mumble/auditable").get_json()
301 self.assertFalse(data["auditable"])
302 self.assertEqual(data["status"], "error")
303 self.assertEqual(data["revision"], 1721)
304 self.assertEqual(data["architecture"], "amd64")
306 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
307 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
308 def test_bounded_scan_is_not_an_error(self, mock_map, mock_details):
309 # Hitting the scan bounds must not pin an error on a snap forever.
310 mock_details.return_value = _details(
311 [_channel("amd64", "latest", "stable", 1721)]
312 )
313 mock_map.return_value = _provenance({}, github_repository=None)
315 data = self.client.get("/api/mumble/auditable").get_json()
317 self.assertFalse(data["auditable"])
318 self.assertEqual(data["status"], "not-provided")
320 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
321 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
322 def test_deleted_repository_is_not_verified(self, mock_map, mock_details):
323 # The commit link would 404, so offering it is worse than nothing.
324 mock_details.return_value = _details(
325 [_channel("amd64", "latest", "stable", 1721)]
326 )
327 mock_map.return_value = _provenance(
328 {"1721": {"amd64": VERIFIED_BUILD}}
329 )
330 self.public_patch.return_value = False
332 data = self.client.get("/api/mumble/auditable").get_json()
334 self.assertFalse(data["auditable"])
335 self.assertEqual(data["status"], "not-provided")
336 self.assertNotIn("commit_url", data)
338 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
339 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
340 def test_build_without_commit_url_is_not_an_error(
341 self, mock_map, mock_details
342 ):
343 # Build found, but no GitHub commit to link: settled, not a failure.
344 mock_details.return_value = _details(
345 [_channel("amd64", "latest", "stable", 1721)]
346 )
347 mock_map.return_value = _provenance(
348 {"1721": {"amd64": {"commit_sha": "aaa", "commit_url": None}}},
349 github_repository=None,
350 )
352 data = self.client.get("/api/mumble/auditable").get_json()
354 self.assertFalse(data["auditable"])
355 self.assertEqual(data["status"], "not-provided")
357 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
358 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
359 def test_hit_in_truncated_scan_is_still_verified(
360 self, mock_map, mock_details
361 ):
362 # A found revision is authoritative however many pages went unread.
363 mock_details.return_value = _details(
364 [_channel("amd64", "latest", "stable", 1721)]
365 )
366 mock_map.return_value = _provenance(
367 {"1721": {"amd64": VERIFIED_BUILD}}
368 )
370 data = self.client.get("/api/mumble/auditable").get_json()
372 self.assertTrue(data["auditable"])
373 self.assertEqual(data["status"], "verified")
375 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
376 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
377 def test_default_channel_arch_without_that_risk_is_skipped(
378 self, mock_map, mock_details
379 ):
380 mock_details.return_value = _details(
381 [
382 _channel("amd64", "latest", "edge", 900),
383 _channel("arm64", "latest", "stable", 1721),
384 ]
385 )
386 mock_map.return_value = _provenance(
387 {"1721": {"arm64": VERIFIED_BUILD}}
388 )
390 data = self.client.get("/api/mumble/auditable").get_json()
392 self.assertEqual(data["architecture"], "arm64")
393 self.assertEqual(data["revision"], 1721)
394 self.assertTrue(data["auditable"])
396 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
397 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
398 def test_amd64_preferred_when_it_publishes_the_default_channel(
399 self, mock_map, mock_details
400 ):
401 mock_details.return_value = _details(
402 [
403 _channel("arm64", "latest", "stable", 1798),
404 _channel("amd64", "latest", "stable", 1721),
405 ]
406 )
407 mock_map.return_value = _provenance(
408 {"1721": {"amd64": VERIFIED_BUILD}}
409 )
411 data = self.client.get("/api/mumble/auditable").get_json()
413 self.assertEqual(data["architecture"], "amd64")
414 self.assertEqual(data["revision"], 1721)
416 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
417 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
418 def test_error_carries_the_failure_reason(self, mock_map, mock_details):
419 mock_details.return_value = _details(
420 [_channel("amd64", "latest", "stable", 1721)]
421 )
422 mock_map.return_value = _provenance(
423 {}, failed=True, reason="launchpad_timeout"
424 )
426 data = self.client.get("/api/mumble/auditable").get_json()
428 self.assertEqual(data["status"], "error")
429 self.assertEqual(data["reason"], "launchpad_timeout")
431 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
432 def test_store_timeout_is_reported(self, mock_details):
433 mock_details.side_effect = ApiTimeoutError("took too long")
435 data = self.client.get("/api/mumble/auditable").get_json()
437 self.assertEqual(data["status"], "error")
438 self.assertEqual(data["reason"], "store_timeout")
440 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
441 def test_unexpected_errors_are_distinguishable(self, mock_details):
442 mock_details.side_effect = AttributeError("bug in our own code")
444 data = self.client.get("/api/mumble/auditable").get_json()
446 self.assertEqual(data["status"], "error")
447 self.assertEqual(data["reason"], "unexpected_error")
450class TestAuditableRevisionsEndpoint(TestEndpoints):
451 def setUp(self):
452 super().setUp()
453 self.cache_patch = patch("webapp.endpoints.snaps.redis_cache").start()
454 self.cache_patch.get.return_value = None
455 # Real HTTP; keep it off the network.
456 self.public_patch = patch(
457 "webapp.endpoints.snaps.repository_is_public", return_value=True
458 ).start()
459 self.details_patch = patch(
460 "webapp.endpoints.snaps.device_gateway.get_item_details",
461 return_value=_details(
462 [
463 _channel("amd64", "latest", "stable", 1721),
464 _channel("arm64", "latest", "stable", 1798),
465 ]
466 ),
467 ).start()
468 self.addCleanup(patch.stopall)
470 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
471 def test_scan_is_told_which_revisions_are_released(self, mock_map):
472 mock_map.return_value = _provenance({})
474 self.client.get("/api/mumble/auditable-revisions")
476 self.assertEqual(mock_map.call_args.kwargs["wanted"], {"1721", "1798"})
478 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
479 def test_details_are_not_fetched_on_cache_hit(self, mock_map):
480 self.cache_patch.get.return_value = _provenance(
481 {"1721": {"amd64": VERIFIED_BUILD}}
482 )
484 data = self.client.get("/api/mumble/auditable-revisions").get_json()
486 self.assertIn("1721", data["revisions"])
487 self.details_patch.assert_not_called()
488 mock_map.assert_not_called()
490 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
491 def test_returns_flattened_revisions(self, mock_map):
492 mock_map.return_value = _provenance(
493 {
494 "1721": {"amd64": VERIFIED_BUILD},
495 "1798": {"arm64": VERIFIED_BUILD},
496 }
497 )
499 data = self.client.get("/api/mumble/auditable-revisions").get_json()
501 self.assertEqual(data["github_repository"], "snapcrafters/mumble")
502 self.assertIn("1721", data["revisions"])
503 self.assertIn("1798", data["revisions"])
504 self.assertEqual(
505 data["revisions"]["1721"]["commit_sha"], "10c7c9e1234567890"
506 )
508 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
509 def test_revisions_failure_reports_error(self, mock_map):
510 mock_map.side_effect = Exception("launchpad down")
512 response = self.app.test_client().get(
513 "/api/mumble/auditable-revisions"
514 )
516 self.assertEqual(response.status_code, 200)
517 self.assertEqual(
518 response.get_json(),
519 {
520 "github_repository": None,
521 "revisions": {},
522 "error": True,
523 "reason": "unexpected_error",
524 },
525 )
526 self.assertEqual(response.cache_control.max_age, FAILED_PROVENANCE_TTL)
528 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
529 def test_revisions_omitted_when_repository_is_gone(self, mock_map):
530 mock_map.return_value = _provenance(
531 {"1721": {"amd64": VERIFIED_BUILD}}
532 )
533 self.public_patch.return_value = False
535 data = self.client.get("/api/mumble/auditable-revisions").get_json()
537 self.assertEqual(data["revisions"], {})
538 self.assertFalse(data["error"])
540 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
541 def test_revisions_failed_scan_sets_error_flag(self, mock_map):
542 mock_map.return_value = _provenance(
543 {"1721": {"amd64": VERIFIED_BUILD}}, failed=True
544 )
546 data = self.client.get("/api/mumble/auditable-revisions").get_json()
548 self.assertTrue(data["error"])
549 # Partial data is still returned.
550 self.assertIn("1721", data["revisions"])
552 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
553 def test_revisions_truncated_scan_is_not_an_error(self, mock_map):
554 # Bounded by LP_MAX_BUILD_PAGES by design, so not a failure.
555 mock_map.return_value = _provenance(
556 {"1721": {"amd64": VERIFIED_BUILD}}, failed=False
557 )
559 # Logged-in responses are rewritten to `private`, dropping max-age.
560 response = self.app.test_client().get(
561 "/api/mumble/auditable-revisions"
562 )
563 data = response.get_json()
565 self.assertFalse(data["error"])
566 self.assertIn("1721", data["revisions"])
567 self.assertEqual(response.cache_control.max_age, 3600)