Skip to content

Commit f56f0f0

Browse files
Maffoochclaude
andcommitted
test(vulnerability_id): add upgrade read-parity tests (legacy rows -> backfill -> flag parity)
TestReadSeamFlagParity seeds via the dual-write seam, so both stores are trivially in sync. These new tests cover the actual UPGRADE scenario a deployment hits: legacy Vulnerability_Id rows written the old way (no entity refs), then backfilled by migrate_vulnerability_ids, then read flag-off (legacy) vs flag-on (entity). - test_backfilled_normal_data_reads_identically: for seam-shaped data (cve == first/ lowest-PK row) get_vulnerability_ids, the vulnerability_ids property, and the raw v2 wire shape are byte-identical across the flag. - test_backfilled_cve_not_first_row_preserves_hash_and_property: anomalous data where cve is not the first-created legacy row keeps hash + property byte-identical (both are order-insensitive by construction); only the RAW v2 wire ORDER differs (legacy PK-asc vs entity cve-first, same set) — asserted so the bounded divergence is deliberate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ff473c6 commit f56f0f0

1 file changed

Lines changed: 85 additions & 0 deletions

File tree

unittests/test_vulnerability_id.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,88 @@ def test_verify_command_fails_on_discrepancy(self):
233233
Vulnerability_Id.objects.create(finding=finding, vulnerability_id="CVE-BAD-1")
234234
with self.assertRaises(SystemExit):
235235
call_command("verify_vulnerability_ids")
236+
237+
238+
class TestUpgradeReadParity(VulnerabilityIdDualWriteTestCase):
239+
240+
"""
241+
Upgrade path: legacy Vulnerability_Id rows written the OLD way (before the entity existed) and
242+
then backfilled by migrate_vulnerability_ids must read back IDENTICALLY in both flag states.
243+
244+
Distinct from TestReadSeamFlagParity, which seeds through the dual-write seam (so both stores
245+
are trivially in sync). Here only the LEGACY rows are written — the true pre-upgrade state —
246+
then backfilled, then flag-off (legacy read) is compared against flag-on (entity read). This is
247+
the scenario a real deployment actually hits when the flag is first turned on after a migration.
248+
"""
249+
250+
def _seed_legacy_only(self, finding, cve, ordered_ids):
251+
"""
252+
Write legacy rows directly (creation order == PK order), set the primary cve, and assert
253+
the pre-migration state (no entity references yet) — i.e. exactly what pre-upgrade data looks
254+
like.
255+
"""
256+
finding.cve = cve
257+
finding.save()
258+
for vid in ordered_ids:
259+
Vulnerability_Id.objects.create(finding=finding, vulnerability_id=vid)
260+
self.assertEqual(FindingVulnerabilityReference.objects.filter(finding=finding).count(), 0)
261+
262+
def _fresh(self, pk):
263+
# Fresh instance per read: vulnerability_ids is a cached_property.
264+
return Finding.objects.get(pk=pk)
265+
266+
def _reads(self, pk):
267+
return (
268+
self._fresh(pk).get_vulnerability_ids(),
269+
self._fresh(pk).vulnerability_ids,
270+
[r["vulnerability_id"] for r in VulnerabilityIdsField().to_representation(self._fresh(pk))],
271+
)
272+
273+
def test_backfilled_normal_data_reads_identically(self):
274+
# Seam/historical invariant: the primary cve IS the first-created (lowest-PK) legacy row.
275+
finding = self._make_finding("upgrade-normal")
276+
self._seed_legacy_only(finding, cve="CVE-U-1", ordered_ids=["CVE-U-1", "CVE-U-2", "CVE-U-3"])
277+
278+
call_command("migrate_vulnerability_ids")
279+
self.assertEqual(self._refs(finding)[0], "CVE-U-1") # cve at order 0
280+
281+
with override_settings(V3_FEATURE_VULNERABILITY_IDS=False):
282+
off_hash, off_prop, off_wire = self._reads(finding.pk)
283+
with override_settings(V3_FEATURE_VULNERABILITY_IDS=True):
284+
on_hash, on_prop, on_wire = self._reads(finding.pk)
285+
286+
# Full parity: hash input, the merged property, AND the raw v2 wire order.
287+
self.assertEqual(off_hash, on_hash)
288+
self.assertEqual(off_prop, on_prop)
289+
self.assertEqual(off_wire, on_wire)
290+
self.assertEqual(off_prop, ["CVE-U-1", "CVE-U-2", "CVE-U-3"])
291+
self.assertEqual(off_wire, ["CVE-U-1", "CVE-U-2", "CVE-U-3"])
292+
293+
def test_backfilled_cve_not_first_row_preserves_hash_and_property(self):
294+
# Anomalous pre-upgrade data: the primary cve is NOT the first-created legacy row (e.g. the
295+
# cve field was edited independently of the id rows historically). The hash (sorted) and the
296+
# vulnerability_ids property (cve-merged + deduped) are order-insensitive by construction and
297+
# stay byte-identical across flag states. Only the RAW v2 wire ORDER differs — legacy reads
298+
# PK-ascending, the entity reads cve-first — carrying the SAME set in a different order. This
299+
# divergence is bounded to this anomalous shape (never produced by save_vulnerability_ids,
300+
# which always makes cve == ids[0] == the lowest-PK row) and is asserted here so any future
301+
# change to it is deliberate, not silent.
302+
finding = self._make_finding("upgrade-cve-mid")
303+
self._seed_legacy_only(finding, cve="CVE-M-3", ordered_ids=["CVE-M-1", "CVE-M-2", "CVE-M-3"])
304+
305+
call_command("migrate_vulnerability_ids")
306+
self.assertEqual(self._refs(finding)[0], "CVE-M-3") # cve at order 0 despite being last-created
307+
308+
with override_settings(V3_FEATURE_VULNERABILITY_IDS=False):
309+
off_hash, off_prop, off_wire = self._reads(finding.pk)
310+
with override_settings(V3_FEATURE_VULNERABILITY_IDS=True):
311+
on_hash, on_prop, on_wire = self._reads(finding.pk)
312+
313+
# Hash + property: byte-identical across the flag.
314+
self.assertEqual(off_hash, on_hash)
315+
self.assertEqual(off_prop, on_prop)
316+
self.assertEqual(off_prop, ["CVE-M-3", "CVE-M-1", "CVE-M-2"]) # cve first, then PK-asc rest
317+
# Raw v2 wire: same set, different order (the documented, bounded divergence).
318+
self.assertEqual(off_wire, ["CVE-M-1", "CVE-M-2", "CVE-M-3"]) # legacy PK order
319+
self.assertEqual(on_wire, ["CVE-M-3", "CVE-M-1", "CVE-M-2"]) # entity cve-first order
320+
self.assertEqual(set(off_wire), set(on_wire))

0 commit comments

Comments
 (0)