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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-20 22:09 +0000
1from unittest.mock import patch
3from tests.endpoints.endpoint_testing import TestEndpoints
4from webapp.endpoints.snaps import (
5 FAILED_PROVENANCE_TTL,
6 _get_provenance_map,
7)
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 }
26def _details(channel_map, default_track="latest"):
27 return {
28 "channel-map": channel_map,
29 "default-track": default_track,
30 "snap": {},
31 }
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 }
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}
59class TestProvenanceMapCaching(TestEndpoints):
60 """What gets cached decides whether a Launchpad wobble becomes a storm."""
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)
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"]
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)
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)
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
96 result = _get_provenance_map("mumble")
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"])
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)
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 )
129 response = self.app.test_client().get("/api/mumble/auditable")
130 data = response.get_json()
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)
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 )
159 data = self.client.get("/api/mumble/auditable").get_json()
161 self.assertTrue(data["auditable"])
162 self.assertEqual(data["architecture"], "amd64")
163 self.assertEqual(data["revision"], 1721)
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 )
182 data = self.client.get("/api/mumble/auditable").get_json()
184 self.assertTrue(data["auditable"])
185 self.assertEqual(data["architecture"], "arm64")
186 self.assertEqual(data["revision"], 800)
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({})
198 data = self.client.get("/api/mumble/auditable").get_json()
200 self.assertFalse(data["auditable"])
201 self.assertEqual(data["status"], "unavailable")
202 self.assertEqual(data["revision"], 1721)
203 self.assertEqual(data["architecture"], "amd64")
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)
214 data = self.client.get("/api/mumble/auditable").get_json()
216 self.assertFalse(data["auditable"])
217 self.assertEqual(data["status"], "not-provided")
218 self.assertEqual(data["revision"], 1721)
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 )
232 data = self.client.get("/api/mumble/auditable").get_json()
234 self.assertFalse(data["auditable"])
235 self.assertEqual(data["status"], "not-provided")
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")
245 response = self.app.test_client().get("/api/mumble/auditable")
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)
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)
262 data = self.client.get("/api/mumble/auditable").get_json()
264 self.assertFalse(data["auditable"])
265 self.assertEqual(data["status"], "error")
266 self.assertEqual(data["revision"], 1721)
267 self.assertEqual(data["architecture"], "amd64")
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)
278 data = self.client.get("/api/mumble/auditable").get_json()
280 self.assertFalse(data["auditable"])
281 self.assertEqual(data["status"], "not-provided")
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
295 data = self.client.get("/api/mumble/auditable").get_json()
297 self.assertFalse(data["auditable"])
298 self.assertEqual(data["status"], "not-provided")
299 self.assertNotIn("commit_url", data)
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 )
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_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 )
333 data = self.client.get("/api/mumble/auditable").get_json()
335 self.assertTrue(data["auditable"])
336 self.assertEqual(data["status"], "verified")
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)
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 )
359 data = self.client.get("/api/mumble/auditable-revisions").get_json()
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 )
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")
372 response = self.app.test_client().get(
373 "/api/mumble/auditable-revisions"
374 )
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)
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
390 data = self.client.get("/api/mumble/auditable-revisions").get_json()
392 self.assertEqual(data["revisions"], {})
393 self.assertFalse(data["error"])
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 )
401 data = self.client.get("/api/mumble/auditable-revisions").get_json()
403 self.assertTrue(data["error"])
404 # Partial data is still returned.
405 self.assertIn("1721", data["revisions"])
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 )
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()
420 self.assertFalse(data["error"])
421 self.assertIn("1721", data["revisions"])
422 self.assertEqual(response.cache_control.max_age, 3600)