1616
1717import logging
1818
19+ from django .conf import settings
1920from django .contrib .auth .models import User
2021from django .test import override_settings
2122from django .utils import timezone
2223
23- from dojo .models import Engagement , Finding , Product , Product_Type , Test , Test_Type
24+ from dojo .models import Endpoint , Engagement , Finding , Product , Product_Type , Test , Test_Type
2425from dojo .product .helpers import propagate_tags_on_product_sync
2526from unittests .dojo_test_case import DojoTestCase
2627
@@ -61,6 +62,34 @@ def _make_product_with_findings(name: str, *, n_findings: int, tags: list[str] |
6162 return product
6263
6364
65+ def _make_endpoints (product : Product , n : int ) -> None :
66+ """Create N Endpoints attached directly to the product (V2 only)."""
67+ for i in range (n ):
68+ ep = Endpoint (host = f"perf-{ product .id } -{ i } .example.com" , product = product )
69+ ep .save ()
70+
71+
72+ def _make_locations (product : Product , n : int ) -> None :
73+ """Create N URL Locations attached to the product via LocationManager.persist (V3 only)."""
74+ # Local imports so the file remains importable when V3_FEATURE_LOCATIONS=False.
75+ from dojo .importers .location_manager import LocationManager # noqa: PLC0415
76+ from dojo .tools .locations import LocationData # noqa: PLC0415
77+
78+ finding = Finding .objects .filter (test__engagement__product = product ).first ()
79+ if finding is None :
80+ # _make_product_with_findings should have been called first with n_findings>=1.
81+ msg = "_make_locations requires the product to have at least one Finding"
82+ raise RuntimeError (msg )
83+
84+ loc_data = [
85+ LocationData (type = "url" , data = {"url" : f"https://perf-{ product .id } -{ i } .example.com" })
86+ for i in range (n )
87+ ]
88+ mgr = LocationManager (product )
89+ mgr .record_locations_for_finding (finding , loc_data )
90+ mgr .persist ()
91+
92+
6493@override_settings (
6594 CELERY_TASK_ALWAYS_EAGER = True ,
6695 CELERY_TASK_EAGER_PROPAGATES = True ,
@@ -236,6 +265,75 @@ def test_baseline_finding_remove_inherited_tag_sticky_re_adds(self):
236265 # Sticky re-adds the inherited tag
237266 self .assertIn ("inherited" , {t .name for t in finding .tags .all ()})
238267
268+ # ------------------------------------------------------------------
269+ # V2: propagation to Endpoints (skipped under V3_FEATURE_LOCATIONS)
270+ # ------------------------------------------------------------------
271+
272+ @override_settings (V3_FEATURE_LOCATIONS = False )
273+ def test_baseline_product_tag_add_propagates_to_100_endpoints_v2 (self ):
274+ """`product.tags.add("x")` then sync -> propagate to 100 Endpoints (V2)."""
275+ product = _make_product_with_findings ("perf-add-eps" , n_findings = 0 , tags = ["initial" ])
276+ _make_endpoints (product , n = 100 )
277+
278+ with self .assertNumQueries (self .EXPECTED_PRODUCT_TAG_ADD_100_ENDPOINTS ):
279+ product .tags .add ("perf-added-ep" )
280+ propagate_tags_on_product_sync (product )
281+
282+ endpoint = Endpoint .objects .filter (product = product ).first ()
283+ self .assertIn ("perf-added-ep" , [t .name for t in endpoint .tags .all ()])
284+
285+ @override_settings (V3_FEATURE_LOCATIONS = False )
286+ def test_baseline_product_tag_remove_propagates_to_100_endpoints_v2 (self ):
287+ """`product.tags.remove("x")` then sync -> remove from 100 Endpoints (V2)."""
288+ product = _make_product_with_findings ("perf-remove-eps" , n_findings = 0 , tags = ["to-remove-ep" , "stays-ep" ])
289+ _make_endpoints (product , n = 100 )
290+
291+ with self .assertNumQueries (self .EXPECTED_PRODUCT_TAG_REMOVE_100_ENDPOINTS ):
292+ product .tags .remove ("to-remove-ep" )
293+ propagate_tags_on_product_sync (product )
294+
295+ endpoint = Endpoint .objects .filter (product = product ).first ()
296+ endpoint_tag_names = {t .name for t in endpoint .tags .all ()}
297+ self .assertNotIn ("to-remove-ep" , endpoint_tag_names )
298+ self .assertIn ("stays-ep" , endpoint_tag_names )
299+
300+ # ------------------------------------------------------------------
301+ # V3: propagation to Locations (skipped under V2)
302+ # ------------------------------------------------------------------
303+
304+ @override_settings (V3_FEATURE_LOCATIONS = True )
305+ def test_baseline_product_tag_add_propagates_to_100_locations_v3 (self ):
306+ """`product.tags.add("x")` then sync -> propagate to 100 Locations (V3)."""
307+ # Locations are created against a finding; ensure the product has one.
308+ product = _make_product_with_findings ("perf-add-locs" , n_findings = 1 , tags = ["initial" ])
309+ _make_locations (product , n = 100 )
310+
311+ with self .assertNumQueries (self .EXPECTED_PRODUCT_TAG_ADD_100_LOCATIONS ):
312+ product .tags .add ("perf-added-loc" )
313+ propagate_tags_on_product_sync (product )
314+
315+ from dojo .location .models import Location # noqa: PLC0415
316+ loc = Location .objects .filter (products__product = product ).first ()
317+ self .assertIsNotNone (loc )
318+ self .assertIn ("perf-added-loc" , [t .name for t in loc .tags .all ()])
319+
320+ @override_settings (V3_FEATURE_LOCATIONS = True )
321+ def test_baseline_product_tag_remove_propagates_to_100_locations_v3 (self ):
322+ """`product.tags.remove("x")` then sync -> remove from 100 Locations (V3)."""
323+ product = _make_product_with_findings ("perf-remove-locs" , n_findings = 1 , tags = ["to-remove-loc" , "stays-loc" ])
324+ _make_locations (product , n = 100 )
325+
326+ with self .assertNumQueries (self .EXPECTED_PRODUCT_TAG_REMOVE_100_LOCATIONS ):
327+ product .tags .remove ("to-remove-loc" )
328+ propagate_tags_on_product_sync (product )
329+
330+ from dojo .location .models import Location # noqa: PLC0415
331+ loc = Location .objects .filter (products__product = product ).first ()
332+ self .assertIsNotNone (loc )
333+ location_tag_names = {t .name for t in loc .tags .all ()}
334+ self .assertNotIn ("to-remove-loc" , location_tag_names )
335+ self .assertIn ("stays-loc" , location_tag_names )
336+
239337 # ------------------------------------------------------------------
240338 # Pinned baselines (current code; tighten in PR #1 / PR #2)
241339 # ------------------------------------------------------------------
@@ -245,9 +343,30 @@ def test_baseline_finding_remove_inherited_tag_sticky_re_adds(self):
245343
246344 # Calibrated against current `dev` branch behavior.
247345 # Tighten as PR #1 (Phase A) and PR #2 (Phase B) land.
248- EXPECTED_PRODUCT_TAG_ADD_100 = 4758
249- EXPECTED_PRODUCT_TAG_REMOVE_100 = 4540
346+ # Some hot paths execute slightly different code under V2 vs V3
347+ # (V3 walks an extra Location queryset; V2 walks an Endpoint queryset).
348+ # Use ``_pin(v2=..., v3=...)`` to select the appropriate baseline.
349+ @staticmethod
350+ def _pin (* , v2 : int , v3 : int ) -> int :
351+ return v3 if settings .V3_FEATURE_LOCATIONS else v2
352+
353+ @property
354+ def EXPECTED_PRODUCT_TAG_ADD_100 (self ) -> int :
355+ return self ._pin (v2 = 4758 , v3 = 4759 )
356+
357+ @property
358+ def EXPECTED_PRODUCT_TAG_REMOVE_100 (self ) -> int :
359+ return self ._pin (v2 = 4540 , v3 = 4541 )
360+
250361 EXPECTED_CREATE_ONE_FINDING = 64
251362 EXPECTED_CREATE_100_FINDINGS = 4025
252363 EXPECTED_FINDING_ADD_USER_TAG = 17
253364 EXPECTED_FINDING_REMOVE_INHERITED = 44
365+
366+ # V2 endpoint paths (only run when V3_FEATURE_LOCATIONS=False)
367+ EXPECTED_PRODUCT_TAG_ADD_100_ENDPOINTS = 3958
368+ EXPECTED_PRODUCT_TAG_REMOVE_100_ENDPOINTS = 3740
369+
370+ # V3 location paths (only run when V3_FEATURE_LOCATIONS=True)
371+ EXPECTED_PRODUCT_TAG_ADD_100_LOCATIONS = 4532
372+ EXPECTED_PRODUCT_TAG_REMOVE_100_LOCATIONS = 4307
0 commit comments