|
| 1 | +""" |
| 2 | +Query-count baselines for tag inheritance hot paths. |
| 3 | +
|
| 4 | +These tests pin the *current* number of SQL queries DefectDojo issues for each |
| 5 | +hot path under tag inheritance. They are tripwires: future redesign work |
| 6 | +(see /home/valentijn/.claude/plans/tag-inheritance-redesign.md) will reduce |
| 7 | +these numbers, and any regression that pushes them back up will fail loudly. |
| 8 | +
|
| 9 | +Each test: |
| 10 | + - sets up its fixture *outside* the query-counting block |
| 11 | + - exercises one operation under ``assertNumQueries`` with the pinned baseline |
| 12 | + - asserts a positive correctness check so query tightening cannot smuggle in |
| 13 | + a behavior bug. |
| 14 | +""" |
| 15 | +from __future__ import annotations |
| 16 | + |
| 17 | +import logging |
| 18 | + |
| 19 | +from django.contrib.auth.models import User |
| 20 | +from django.test import override_settings |
| 21 | +from django.utils import timezone |
| 22 | + |
| 23 | +from dojo.models import Engagement, Finding, Product, Product_Type, Test, Test_Type |
| 24 | +from dojo.product.helpers import propagate_tags_on_product_sync |
| 25 | +from unittests.dojo_test_case import DojoTestCase |
| 26 | + |
| 27 | +logger = logging.getLogger(__name__) |
| 28 | + |
| 29 | + |
| 30 | +def _make_product_with_findings(name: str, *, n_findings: int, tags: list[str] | None = None) -> Product: |
| 31 | + """ |
| 32 | + Create a Product → Engagement → Test → N Findings tree with inheritance enabled. |
| 33 | +
|
| 34 | + Returns the Product. Internal-only: signal-driven inheritance fires during |
| 35 | + creation, but those queries are not counted (we measure operations against |
| 36 | + the already-built fixture). |
| 37 | + """ |
| 38 | + if tags is None: |
| 39 | + tags = [] |
| 40 | + now = timezone.now() |
| 41 | + user, _ = User.objects.get_or_create(username="tag_perf_user", defaults={"is_active": True}) |
| 42 | + pt, _ = Product_Type.objects.get_or_create(name="Tag Perf Type") |
| 43 | + product = Product.objects.create( |
| 44 | + name=name, |
| 45 | + description="perf", |
| 46 | + prod_type=pt, |
| 47 | + enable_product_tag_inheritance=True, |
| 48 | + ) |
| 49 | + if tags: |
| 50 | + product.tags.add(*tags) |
| 51 | + eng = Engagement.objects.create(product=product, target_start=now, target_end=now) |
| 52 | + tt, _ = Test_Type.objects.get_or_create(name="Tag Perf Test") |
| 53 | + test = Test.objects.create(engagement=eng, test_type=tt, target_start=now, target_end=now) |
| 54 | + for i in range(n_findings): |
| 55 | + Finding.objects.create( |
| 56 | + test=test, |
| 57 | + title=f"Tag Perf Finding {i}", |
| 58 | + severity="Medium", |
| 59 | + reporter=user, |
| 60 | + ) |
| 61 | + return product |
| 62 | + |
| 63 | + |
| 64 | +@override_settings( |
| 65 | + CELERY_TASK_ALWAYS_EAGER=True, |
| 66 | + CELERY_TASK_EAGER_PROPAGATES=True, |
| 67 | +) |
| 68 | +class TagInheritancePerfBaselines(DojoTestCase): |
| 69 | + |
| 70 | + """ |
| 71 | + Pinned query-count baselines for tag inheritance hot paths. |
| 72 | +
|
| 73 | + Celery handling: ``CELERY_TASK_ALWAYS_EAGER=True`` runs all dispatched |
| 74 | + tasks synchronously in the test thread/connection so their queries are |
| 75 | + captured by ``assertNumQueries``. ``CELERY_TASK_EAGER_PROPAGATES`` makes |
| 76 | + eager-mode failures surface as exceptions rather than silently swallowing. |
| 77 | + Product tag tests still call ``propagate_tags_on_product_sync(product)`` |
| 78 | + explicitly because the m2m_changed signal dispatches the Celery task |
| 79 | + with ``countdown=5`` — eager mode runs immediately but ``dojo_dispatch_task`` |
| 80 | + paths can still skip execution depending on ``we_want_async``. Calling the |
| 81 | + sync entry point directly makes the propagation deterministic. |
| 82 | +
|
| 83 | +
|
| 84 | + TEMPORARY: this test class exists to measure progress as the tag |
| 85 | + inheritance redesign lands across multiple PRs. The exact pinned numbers |
| 86 | + are not important on their own — what matters is that they MOVE in the |
| 87 | + expected direction (downward) as PR #1 (Phase A) and PR #2 (Phase B) |
| 88 | + land. Once the redesign is complete and the numbers have stabilized at |
| 89 | + target levels, this whole file can be deleted or the assertions |
| 90 | + rewritten as loose upper bounds. |
| 91 | +
|
| 92 | + Numbers are *current behavior*. Follow-up PRs reduce them. When a |
| 93 | + redesign lowers a number, lower the pin in the same PR; when something |
| 94 | + accidentally raises it, fix the regression rather than raising the pin. |
| 95 | + """ |
| 96 | + |
| 97 | + @classmethod |
| 98 | + def setUpTestData(cls): |
| 99 | + # Enable system-wide inheritance for every test in this class. |
| 100 | + # Per-product flag is also set in _make_product_with_findings as a |
| 101 | + # belt-and-braces measure (tests should not be flag-coupled). |
| 102 | + from dojo.models import System_Settings # noqa: PLC0415 |
| 103 | + ss = System_Settings.objects.get() |
| 104 | + ss.enable_product_tag_inheritance = True |
| 105 | + ss.save() |
| 106 | + |
| 107 | + # ------------------------------------------------------------------ |
| 108 | + # Product tag add / remove → propagate to children |
| 109 | + # ------------------------------------------------------------------ |
| 110 | + |
| 111 | + def test_baseline_product_tag_add_propagates_to_100_findings(self): |
| 112 | + """ |
| 113 | + `product.tags.add("x")` then sync → propagate to 100 findings. |
| 114 | +
|
| 115 | + Hot path: Product tag toggle in the UI on a product with many |
| 116 | + findings. Today's flow runs `obj.save()` per child. Phase A bulk SQL |
| 117 | + will collapse this dramatically. |
| 118 | + """ |
| 119 | + product = _make_product_with_findings("perf-add", n_findings=100, tags=["initial"]) |
| 120 | + |
| 121 | + with self.assertNumQueries(self.EXPECTED_PRODUCT_TAG_ADD_100): |
| 122 | + product.tags.add("perf-added") |
| 123 | + propagate_tags_on_product_sync(product) |
| 124 | + |
| 125 | + # Correctness: a finding under the product now carries the new tag. |
| 126 | + finding = Finding.objects.filter(test__engagement__product=product).first() |
| 127 | + self.assertIn("perf-added", [t.name for t in finding.tags.all()]) |
| 128 | + |
| 129 | + def test_baseline_product_tag_remove_propagates_to_100_findings(self): |
| 130 | + """`product.tags.remove("x")` then sync → remove from 100 findings.""" |
| 131 | + product = _make_product_with_findings("perf-remove", n_findings=100, tags=["to-remove", "stays"]) |
| 132 | + |
| 133 | + with self.assertNumQueries(self.EXPECTED_PRODUCT_TAG_REMOVE_100): |
| 134 | + product.tags.remove("to-remove") |
| 135 | + propagate_tags_on_product_sync(product) |
| 136 | + |
| 137 | + finding = Finding.objects.filter(test__engagement__product=product).first() |
| 138 | + finding_tag_names = {t.name for t in finding.tags.all()} |
| 139 | + self.assertNotIn("to-remove", finding_tag_names) |
| 140 | + self.assertIn("stays", finding_tag_names) |
| 141 | + |
| 142 | + # ------------------------------------------------------------------ |
| 143 | + # Child creation under inheritance-on product |
| 144 | + # ------------------------------------------------------------------ |
| 145 | + |
| 146 | + def test_baseline_create_one_finding_under_inheritance(self): |
| 147 | + """ |
| 148 | + Single Finding.objects.create() on inheritance-on product. |
| 149 | +
|
| 150 | + post_save fires `inherit_tags_on_instance` which calls |
| 151 | + `_manage_inherited_tags` → 2 m2m `.set()` calls per save. This |
| 152 | + baseline pins the per-finding cost. Phase A gates on `created=True` |
| 153 | + so updates stop paying it; Phase B replaces the M2M dance with a |
| 154 | + single JSON column write. |
| 155 | + """ |
| 156 | + product = _make_product_with_findings("perf-create-one", n_findings=0, tags=["t1", "t2"]) |
| 157 | + engagement = Engagement.objects.filter(product=product).first() |
| 158 | + test = Test.objects.filter(engagement=engagement).first() |
| 159 | + user = User.objects.get(username="tag_perf_user") |
| 160 | + |
| 161 | + with self.assertNumQueries(self.EXPECTED_CREATE_ONE_FINDING): |
| 162 | + Finding.objects.create( |
| 163 | + test=test, |
| 164 | + title="single-perf", |
| 165 | + severity="Medium", |
| 166 | + reporter=user, |
| 167 | + ) |
| 168 | + |
| 169 | + # Finding.save() titlecases + truncates the title — look up via test FK |
| 170 | + finding = Finding.objects.filter(test=test).first() |
| 171 | + self.assertIsNotNone(finding) |
| 172 | + self.assertEqual({"t1", "t2"}, {t.name for t in finding.tags.all()}) |
| 173 | + |
| 174 | + def test_baseline_create_100_findings_under_inheritance(self): |
| 175 | + """ |
| 176 | + 100 sequential Finding.objects.create() under inheritance. |
| 177 | +
|
| 178 | + Approximates an importer hot loop. Today every iteration fires |
| 179 | + `_manage_inherited_tags` per finding. After Phase B, wrapping in |
| 180 | + `with tag_inheritance.batch():` should collapse to a single bulk |
| 181 | + sync at exit. |
| 182 | + """ |
| 183 | + product = _make_product_with_findings("perf-create-100", n_findings=0, tags=["t1", "t2"]) |
| 184 | + engagement = Engagement.objects.filter(product=product).first() |
| 185 | + test = Test.objects.filter(engagement=engagement).first() |
| 186 | + user = User.objects.get(username="tag_perf_user") |
| 187 | + |
| 188 | + with self.assertNumQueries(self.EXPECTED_CREATE_100_FINDINGS): |
| 189 | + for i in range(100): |
| 190 | + Finding.objects.create( |
| 191 | + test=test, |
| 192 | + title=f"loop-{i}", |
| 193 | + severity="Medium", |
| 194 | + reporter=user, |
| 195 | + ) |
| 196 | + |
| 197 | + self.assertEqual(100, Finding.objects.filter(test=test).count()) |
| 198 | + any_finding = Finding.objects.filter(test=test).first() |
| 199 | + self.assertEqual({"t1", "t2"}, {t.name for t in any_finding.tags.all()}) |
| 200 | + |
| 201 | + # ------------------------------------------------------------------ |
| 202 | + # Sticky enforcement on child tag edits |
| 203 | + # ------------------------------------------------------------------ |
| 204 | + |
| 205 | + def test_baseline_finding_add_user_tag_sticky_path(self): |
| 206 | + """ |
| 207 | + `finding.tags.add("user-only")` — sticky signal still runs. |
| 208 | +
|
| 209 | + Adding a *non-inherited* tag still fires `m2m_changed` → |
| 210 | + `make_inherited_tags_sticky` → re-checks product tags. Phase B |
| 211 | + moves this work out of the signal entirely. |
| 212 | + """ |
| 213 | + product = _make_product_with_findings("perf-sticky-add", n_findings=1, tags=["inherited"]) |
| 214 | + finding = Finding.objects.filter(test__engagement__product=product).first() |
| 215 | + |
| 216 | + with self.assertNumQueries(self.EXPECTED_FINDING_ADD_USER_TAG): |
| 217 | + finding.tags.add("user-only") |
| 218 | + |
| 219 | + finding_tag_names = {t.name for t in finding.tags.all()} |
| 220 | + self.assertIn("user-only", finding_tag_names) |
| 221 | + self.assertIn("inherited", finding_tag_names) # still sticky |
| 222 | + |
| 223 | + def test_baseline_finding_remove_inherited_tag_sticky_re_adds(self): |
| 224 | + """ |
| 225 | + `finding.tags.remove("inherited")` — sticky re-adds. |
| 226 | +
|
| 227 | + Most expensive sticky path: signal re-applies inherited tags via |
| 228 | + `inherit_tags` → `_manage_inherited_tags` → 2 M2M `.set()` calls. |
| 229 | + """ |
| 230 | + product = _make_product_with_findings("perf-sticky-rm", n_findings=1, tags=["inherited"]) |
| 231 | + finding = Finding.objects.filter(test__engagement__product=product).first() |
| 232 | + |
| 233 | + with self.assertNumQueries(self.EXPECTED_FINDING_REMOVE_INHERITED): |
| 234 | + finding.tags.remove("inherited") |
| 235 | + |
| 236 | + # Sticky re-adds the inherited tag |
| 237 | + self.assertIn("inherited", {t.name for t in finding.tags.all()}) |
| 238 | + |
| 239 | + # ------------------------------------------------------------------ |
| 240 | + # Pinned baselines (current code; tighten in PR #1 / PR #2) |
| 241 | + # ------------------------------------------------------------------ |
| 242 | + # Calibrated against current implementation. If a redesign lowers a |
| 243 | + # number, lower the pin in the same PR. If a regression raises it, fix |
| 244 | + # the regression. NEVER raise a pin without justification. |
| 245 | + |
| 246 | + # Calibrated against current `dev` branch behavior. |
| 247 | + # 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 |
| 250 | + EXPECTED_CREATE_ONE_FINDING = 64 |
| 251 | + EXPECTED_CREATE_100_FINDINGS = 4025 |
| 252 | + EXPECTED_FINDING_ADD_USER_TAG = 17 |
| 253 | + EXPECTED_FINDING_REMOVE_INHERITED = 44 |
0 commit comments