Coverage for tests/endpoints/tests_snaps_auditable.py: 100%
244 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-10 22:08 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-10 22:08 +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)
72 def _ttl_for(self, provenance_map):
73 with patch(
74 "webapp.endpoints.snaps.launchpad_provenance."
75 "build_provenance_map",
76 return_value=provenance_map,
77 ):
78 _get_provenance_map("mumble")
79 return self.cache_patch.set.call_args.kwargs["ttl"]
81 def test_successful_scan_is_cached_for_an_hour(self):
82 # Includes scans that stopped at their bounds: partial but valid.
83 self.assertEqual(self._ttl_for(_provenance({})), 3600)
85 def test_failed_scan_is_cached_briefly(self):
86 ttl = self._ttl_for(_provenance({}, failed=True))
87 self.assertEqual(ttl, FAILED_PROVENANCE_TTL)
88 self.assertLess(ttl, 3600)
90 @patch("webapp.endpoints.snaps.repository_is_public")
91 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
92 def test_repository_is_checked_once_and_cached(
93 self, mock_map, mock_public
94 ):
95 # One call per map build, not one per page view.
96 mock_map.return_value = _provenance({})
97 mock_public.return_value = False
99 result = _get_provenance_map("mumble")
101 mock_public.assert_called_once_with("snapcrafters/mumble")
102 self.assertFalse(result["source_available"])
103 cached = self.cache_patch.set.call_args.args[1]
104 self.assertFalse(cached["source_available"])
107class TestAuditableEndpoint(TestEndpoints):
108 def setUp(self):
109 super().setUp()
110 # Force cache miss so build_provenance_map is always exercised.
111 cache_patcher = patch("webapp.endpoints.snaps.redis_cache")
112 self.cache_patch = cache_patcher.start()
113 self.cache_patch.get.return_value = None
114 self.addCleanup(cache_patcher.stop)
115 # Real HTTP; keep it off the network.
116 public_patcher = patch(
117 "webapp.endpoints.snaps.repository_is_public", return_value=True
118 )
119 self.public_patch = public_patcher.start()
120 self.addCleanup(public_patcher.stop)
122 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
123 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
124 def test_auditable_hit(self, mock_map, mock_details):
125 mock_details.return_value = _details(
126 [_channel("amd64", "latest", "stable", 1721)]
127 )
128 mock_map.return_value = _provenance(
129 {"1721": {"amd64": VERIFIED_BUILD}}
130 )
132 response = self.app.test_client().get("/api/mumble/auditable")
133 data = response.get_json()
135 self.assertEqual(response.status_code, 200)
136 self.assertTrue(data["auditable"])
137 self.assertEqual(data["status"], "verified")
138 self.assertEqual(data["revision"], 1721)
139 self.assertEqual(data["architecture"], "amd64")
140 self.assertEqual(data["commit_sha"], "10c7c9e1234567890")
141 self.assertEqual(data["github_repository"], "snapcrafters/mumble")
142 self.assertIn("commit/10c7c9e", data["commit_url"])
143 self.assertEqual(data["build_id"], "216436")
144 self.assertEqual(response.cache_control.max_age, 3600)
146 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
147 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
148 def test_prefers_amd64(self, mock_map, mock_details):
149 mock_details.return_value = _details(
150 [
151 _channel("arm64", "latest", "stable", 1798),
152 _channel("amd64", "latest", "stable", 1721),
153 ]
154 )
155 mock_map.return_value = _provenance(
156 {
157 "1721": {"amd64": VERIFIED_BUILD},
158 "1798": {"arm64": VERIFIED_BUILD},
159 }
160 )
162 data = self.client.get("/api/mumble/auditable").get_json()
164 self.assertTrue(data["auditable"])
165 self.assertEqual(data["architecture"], "amd64")
166 self.assertEqual(data["revision"], 1721)
168 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
169 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
170 def test_deterministic_arch_fallback(self, mock_map, mock_details):
171 # No amd64 -> first architecture sorted alphabetically (arm64).
172 mock_details.return_value = _details(
173 [
174 _channel("armhf", "latest", "stable", 900),
175 _channel("arm64", "latest", "stable", 800),
176 ]
177 )
178 mock_map.return_value = _provenance(
179 {
180 "800": {"arm64": VERIFIED_BUILD},
181 "900": {"armhf": VERIFIED_BUILD},
182 }
183 )
185 data = self.client.get("/api/mumble/auditable").get_json()
187 self.assertTrue(data["auditable"])
188 self.assertEqual(data["architecture"], "arm64")
189 self.assertEqual(data["revision"], 800)
191 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
192 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
193 def test_unavailable_when_revision_absent(self, mock_map, mock_details):
194 # Public recipe exists (github_repository set) but this revision has no
195 # matching build -> "unavailable", not "not-provided".
196 mock_details.return_value = _details(
197 [_channel("amd64", "latest", "stable", 1721)]
198 )
199 mock_map.return_value = _provenance({})
201 data = self.client.get("/api/mumble/auditable").get_json()
203 self.assertFalse(data["auditable"])
204 self.assertEqual(data["status"], "unavailable")
205 self.assertEqual(data["revision"], 1721)
206 self.assertEqual(data["architecture"], "amd64")
208 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
209 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
210 def test_not_provided_when_no_public_recipe(self, mock_map, mock_details):
211 # No public GitHub recipe (private / non-GitHub) -> "not-provided".
212 mock_details.return_value = _details(
213 [_channel("amd64", "latest", "stable", 1721)]
214 )
215 mock_map.return_value = _provenance({}, github_repository=None)
217 data = self.client.get("/api/mumble/auditable").get_json()
219 self.assertFalse(data["auditable"])
220 self.assertEqual(data["status"], "not-provided")
221 self.assertEqual(data["revision"], 1721)
223 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
224 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
225 def test_non_github_repo_is_not_provided(self, mock_map, mock_details):
226 mock_details.return_value = _details(
227 [_channel("amd64", "latest", "stable", 1721)]
228 )
229 # Non-GitHub build: commit_url is None and no github repository.
230 no_link_build = dict(VERIFIED_BUILD, commit_url=None)
231 mock_map.return_value = _provenance(
232 {"1721": {"amd64": no_link_build}}, github_repository=None
233 )
235 data = self.client.get("/api/mumble/auditable").get_json()
237 self.assertFalse(data["auditable"])
238 self.assertEqual(data["status"], "not-provided")
240 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
241 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
242 def test_upstream_failure_reports_error(self, mock_map, mock_details):
243 mock_details.return_value = _details(
244 [_channel("amd64", "latest", "stable", 1721)]
245 )
246 mock_map.side_effect = Exception("launchpad down")
248 response = self.app.test_client().get("/api/mumble/auditable")
250 self.assertEqual(response.status_code, 200)
251 self.assertEqual(
252 response.get_json(),
253 {
254 "auditable": False,
255 "status": "error",
256 "reason": "unexpected_error",
257 },
258 )
259 self.assertEqual(response.cache_control.max_age, FAILED_PROVENANCE_TTL)
261 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
262 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
263 def test_failed_scan_reports_error(self, mock_map, mock_details):
264 # The one case where retrying might help.
265 mock_details.return_value = _details(
266 [_channel("amd64", "latest", "stable", 1721)]
267 )
268 mock_map.return_value = _provenance({}, failed=True)
270 data = self.client.get("/api/mumble/auditable").get_json()
272 self.assertFalse(data["auditable"])
273 self.assertEqual(data["status"], "error")
274 self.assertEqual(data["revision"], 1721)
275 self.assertEqual(data["architecture"], "amd64")
277 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
278 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
279 def test_bounded_scan_is_not_an_error(self, mock_map, mock_details):
280 # Hitting the scan bounds must not pin an error on a snap forever.
281 mock_details.return_value = _details(
282 [_channel("amd64", "latest", "stable", 1721)]
283 )
284 mock_map.return_value = _provenance({}, github_repository=None)
286 data = self.client.get("/api/mumble/auditable").get_json()
288 self.assertFalse(data["auditable"])
289 self.assertEqual(data["status"], "not-provided")
291 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
292 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
293 def test_deleted_repository_is_not_verified(self, mock_map, mock_details):
294 # The commit link would 404, so offering it is worse than nothing.
295 mock_details.return_value = _details(
296 [_channel("amd64", "latest", "stable", 1721)]
297 )
298 mock_map.return_value = _provenance(
299 {"1721": {"amd64": VERIFIED_BUILD}}
300 )
301 self.public_patch.return_value = False
303 data = self.client.get("/api/mumble/auditable").get_json()
305 self.assertFalse(data["auditable"])
306 self.assertEqual(data["status"], "not-provided")
307 self.assertNotIn("commit_url", data)
309 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
310 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
311 def test_build_without_commit_url_is_not_an_error(
312 self, mock_map, mock_details
313 ):
314 # Build found, but no GitHub commit to link: settled, not a failure.
315 mock_details.return_value = _details(
316 [_channel("amd64", "latest", "stable", 1721)]
317 )
318 mock_map.return_value = _provenance(
319 {"1721": {"amd64": {"commit_sha": "aaa", "commit_url": None}}},
320 github_repository=None,
321 )
323 data = self.client.get("/api/mumble/auditable").get_json()
325 self.assertFalse(data["auditable"])
326 self.assertEqual(data["status"], "not-provided")
328 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
329 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
330 def test_hit_in_truncated_scan_is_still_verified(
331 self, mock_map, mock_details
332 ):
333 # A found revision is authoritative however many pages went unread.
334 mock_details.return_value = _details(
335 [_channel("amd64", "latest", "stable", 1721)]
336 )
337 mock_map.return_value = _provenance(
338 {"1721": {"amd64": VERIFIED_BUILD}}
339 )
341 data = self.client.get("/api/mumble/auditable").get_json()
343 self.assertTrue(data["auditable"])
344 self.assertEqual(data["status"], "verified")
346 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
347 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
348 def test_default_channel_arch_without_that_risk_is_skipped(
349 self, mock_map, mock_details
350 ):
351 mock_details.return_value = _details(
352 [
353 _channel("amd64", "latest", "edge", 900),
354 _channel("arm64", "latest", "stable", 1721),
355 ]
356 )
357 mock_map.return_value = _provenance(
358 {"1721": {"arm64": VERIFIED_BUILD}}
359 )
361 data = self.client.get("/api/mumble/auditable").get_json()
363 self.assertEqual(data["architecture"], "arm64")
364 self.assertEqual(data["revision"], 1721)
365 self.assertTrue(data["auditable"])
367 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
368 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
369 def test_amd64_preferred_when_it_publishes_the_default_channel(
370 self, mock_map, mock_details
371 ):
372 mock_details.return_value = _details(
373 [
374 _channel("arm64", "latest", "stable", 1798),
375 _channel("amd64", "latest", "stable", 1721),
376 ]
377 )
378 mock_map.return_value = _provenance(
379 {"1721": {"amd64": VERIFIED_BUILD}}
380 )
382 data = self.client.get("/api/mumble/auditable").get_json()
384 self.assertEqual(data["architecture"], "amd64")
385 self.assertEqual(data["revision"], 1721)
387 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
388 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
389 def test_error_carries_the_failure_reason(self, mock_map, mock_details):
390 mock_details.return_value = _details(
391 [_channel("amd64", "latest", "stable", 1721)]
392 )
393 mock_map.return_value = _provenance(
394 {}, failed=True, reason="launchpad_timeout"
395 )
397 data = self.client.get("/api/mumble/auditable").get_json()
399 self.assertEqual(data["status"], "error")
400 self.assertEqual(data["reason"], "launchpad_timeout")
402 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
403 def test_store_timeout_is_reported(self, mock_details):
404 mock_details.side_effect = ApiTimeoutError("took too long")
406 data = self.client.get("/api/mumble/auditable").get_json()
408 self.assertEqual(data["status"], "error")
409 self.assertEqual(data["reason"], "store_timeout")
411 @patch("webapp.endpoints.snaps.device_gateway.get_item_details")
412 def test_unexpected_errors_are_distinguishable(self, mock_details):
413 mock_details.side_effect = AttributeError("bug in our own code")
415 data = self.client.get("/api/mumble/auditable").get_json()
417 self.assertEqual(data["status"], "error")
418 self.assertEqual(data["reason"], "unexpected_error")
421class TestAuditableRevisionsEndpoint(TestEndpoints):
422 def setUp(self):
423 super().setUp()
424 self.cache_patch = patch("webapp.endpoints.snaps.redis_cache").start()
425 self.cache_patch.get.return_value = None
426 # Real HTTP; keep it off the network.
427 self.public_patch = patch(
428 "webapp.endpoints.snaps.repository_is_public", return_value=True
429 ).start()
430 self.addCleanup(patch.stopall)
432 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
433 def test_returns_flattened_revisions(self, mock_map):
434 mock_map.return_value = _provenance(
435 {
436 "1721": {"amd64": VERIFIED_BUILD},
437 "1798": {"arm64": VERIFIED_BUILD},
438 }
439 )
441 data = self.client.get("/api/mumble/auditable-revisions").get_json()
443 self.assertEqual(data["github_repository"], "snapcrafters/mumble")
444 self.assertIn("1721", data["revisions"])
445 self.assertIn("1798", data["revisions"])
446 self.assertEqual(
447 data["revisions"]["1721"]["commit_sha"], "10c7c9e1234567890"
448 )
450 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
451 def test_revisions_failure_reports_error(self, mock_map):
452 mock_map.side_effect = Exception("launchpad down")
454 response = self.app.test_client().get(
455 "/api/mumble/auditable-revisions"
456 )
458 self.assertEqual(response.status_code, 200)
459 self.assertEqual(
460 response.get_json(),
461 {
462 "github_repository": None,
463 "revisions": {},
464 "error": True,
465 "reason": "unexpected_error",
466 },
467 )
468 self.assertEqual(response.cache_control.max_age, FAILED_PROVENANCE_TTL)
470 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
471 def test_revisions_omitted_when_repository_is_gone(self, mock_map):
472 mock_map.return_value = _provenance(
473 {"1721": {"amd64": VERIFIED_BUILD}}
474 )
475 self.public_patch.return_value = False
477 data = self.client.get("/api/mumble/auditable-revisions").get_json()
479 self.assertEqual(data["revisions"], {})
480 self.assertFalse(data["error"])
482 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
483 def test_revisions_failed_scan_sets_error_flag(self, mock_map):
484 mock_map.return_value = _provenance(
485 {"1721": {"amd64": VERIFIED_BUILD}}, failed=True
486 )
488 data = self.client.get("/api/mumble/auditable-revisions").get_json()
490 self.assertTrue(data["error"])
491 # Partial data is still returned.
492 self.assertIn("1721", data["revisions"])
494 @patch("webapp.endpoints.snaps.launchpad_provenance.build_provenance_map")
495 def test_revisions_truncated_scan_is_not_an_error(self, mock_map):
496 # Bounded by LP_MAX_BUILD_PAGES by design, so not a failure.
497 mock_map.return_value = _provenance(
498 {"1721": {"amd64": VERIFIED_BUILD}}, failed=False
499 )
501 # Logged-in responses are rewritten to `private`, dropping max-age.
502 response = self.app.test_client().get(
503 "/api/mumble/auditable-revisions"
504 )
505 data = response.get_json()
507 self.assertFalse(data["error"])
508 self.assertIn("1721", data["revisions"])
509 self.assertEqual(response.cache_control.max_age, 3600)