|
31 | 31 | from trestle.common import const, file_utils, list_utils |
32 | 32 | from trestle.common.model_utils import ModelUtils |
33 | 33 | from trestle.core.commands.author.ssp import SSPAssemble, SSPFilter, SSPGenerate |
| 34 | +from trestle.core.catalog.catalog_writer import CatalogWriter |
| 35 | +from trestle.core.catalog.catalog_interface import CatalogInterface |
34 | 36 | from trestle.core.control_context import ContextPurpose, ControlContext |
| 37 | +from trestle.oscal.common import Parameter1, Property |
35 | 38 | from trestle.core.control_reader import ControlReader |
36 | 39 | from trestle.core.markdown.markdown_api import MarkdownAPI |
37 | 40 | from trestle.core.models.file_content_type import FileContentType |
38 | 41 | from trestle.core.profile_resolver import ProfileResolver |
| 42 | +from unittest.mock import Mock |
39 | 43 |
|
40 | 44 |
|
41 | 45 | prof_name = 'comp_prof' |
@@ -1456,6 +1460,32 @@ def test_ssp_generate_aggregates_no_param_value_orig(tmp_trestle_dir: pathlib.Pa |
1456 | 1460 | assert const.PARAM_VALUE_ORIGIN not in si_7_prm_1.keys() |
1457 | 1461 |
|
1458 | 1462 |
|
| 1463 | +def test_ssp_generate_missing_profile_param_value_origin(tmp_trestle_dir: pathlib.Path) -> None: |
| 1464 | + """Test ssp-generate succeeds when profile set-parameter has no profile-param-value-origin. |
| 1465 | +
|
| 1466 | + Regression test for KeyError raised in catalog_writer._construct_set_parameters_dict |
| 1467 | + when profile-param-value-origin key is absent from parameter dict. |
| 1468 | +
|
| 1469 | + NOTE: This test uses the standard profile fixture which does NOT exercise |
| 1470 | + the AGGREGATES branch — it would pass even on unpatched code. Kept as a |
| 1471 | + smoke test only. See test_ssp_generate_missing_profile_param_value_origin_regression |
| 1472 | + for the true regression guard that fails deterministically on unpatched code. |
| 1473 | + """ |
| 1474 | + args, _ = setup_for_ssp(tmp_trestle_dir, prof_name, ssp_name) |
| 1475 | + args.compdefs = None |
| 1476 | + ssp_cmd = SSPGenerate() |
| 1477 | + # This should not raise KeyError: 'profile-param-value-origin' |
| 1478 | + assert ssp_cmd._run(args) == 0 |
| 1479 | + md_dir = tmp_trestle_dir / ssp_name |
| 1480 | + ac_1 = md_dir / 'ac' / 'ac-1.md' |
| 1481 | + assert ac_1.exists() |
| 1482 | + md_api = MarkdownAPI() |
| 1483 | + header, _ = md_api.processor.process_markdown(ac_1) |
| 1484 | + # Verify ssp-generate completed without KeyError on missing profile-param-value-origin |
| 1485 | + # The header should be populated correctly |
| 1486 | + assert 'x-trestle-set-params' in header |
| 1487 | + |
| 1488 | + |
1459 | 1489 | def test_ssp_generate_includes_all_imp_reqs(tmp_trestle_dir: pathlib.Path) -> None: |
1460 | 1490 | """Test component prose is included for all implemented-requirements regardless of rules.""" |
1461 | 1491 |
|
@@ -1543,3 +1573,186 @@ def test_ssp_generate_includes_all_imp_reqs(tmp_trestle_dir: pathlib.Path) -> No |
1543 | 1573 | 'OSCO' in ac3_content_3 |
1544 | 1574 | and 'Ensure that the Container Network Interface file ownership is set to root:root' in ac3_content_3 |
1545 | 1575 | ) |
| 1576 | + |
| 1577 | + |
| 1578 | +def test_ssp_generate_missing_profile_param_value_origin_regression(tmp_trestle_dir: pathlib.Path) -> None: |
| 1579 | + """Strengthened regression test for #2221. |
| 1580 | +
|
| 1581 | + Verifies that _construct_set_parameters_dict does NOT raise KeyError when: |
| 1582 | + - profile set-parameter has AGGREGATES prop (so PROFILE_PARAM_VALUE_ORIGIN |
| 1583 | + is intentionally never written into new_dict), AND |
| 1584 | + - control parameter also carries AGGREGATES (so pop is called on new_dict) |
| 1585 | +
|
| 1586 | + This test uses profile_aggregation fixture which exercises that exact code path. |
| 1587 | + The previous test (test_ssp_generate_missing_profile_param_value_origin) used |
| 1588 | + a standard profile and did NOT exercise the AGGREGATES branch — meaning it |
| 1589 | + would pass even against the unpatched code. This test would fail on unpatched code. |
| 1590 | + """ |
| 1591 | + args, _ = setup_for_ssp(tmp_trestle_dir, 'profile_aggregation', ssp_name) |
| 1592 | + args.compdefs = None |
| 1593 | + ssp_cmd = SSPGenerate() |
| 1594 | + |
| 1595 | + # On unpatched code this raises: |
| 1596 | + # KeyError: 'profile-param-value-origin' |
| 1597 | + # inside _construct_set_parameters_dict at the line: |
| 1598 | + # new_dict.pop(const.PROFILE_PARAM_VALUE_ORIGIN) # no default |
| 1599 | + assert ssp_cmd._run(args) == 0 |
| 1600 | + |
| 1601 | + md_dir = tmp_trestle_dir / ssp_name |
| 1602 | + si_7 = md_dir / 'si-7.md' |
| 1603 | + assert si_7.exists() |
| 1604 | + |
| 1605 | + md_api = MarkdownAPI() |
| 1606 | + header, _ = md_api.processor.process_markdown(si_7) |
| 1607 | + |
| 1608 | + # si-7_prm_1 exercises the AGGREGATES path. |
| 1609 | + # After the fix, profile-param-value-origin must be absent (popped cleanly). |
| 1610 | + si_7_prm_1 = header['x-trestle-set-params']['si-7_prm_1'] |
| 1611 | + assert const.PROFILE_PARAM_VALUE_ORIGIN not in si_7_prm_1, ( |
| 1612 | + 'profile-param-value-origin should be absent for AGGREGATES params' |
| 1613 | + ) |
| 1614 | + |
| 1615 | + |
| 1616 | +def test_construct_set_parameters_dict_aggregates_no_origin_unit(tmp_path: pathlib.Path) -> None: |
| 1617 | + """Direct unit test for _construct_set_parameters_dict regression (#2221). |
| 1618 | +
|
| 1619 | + Constructs the exact scenario that caused KeyError: |
| 1620 | + - profile set-parameter has 'aggregates' prop and NO param-value-origin |
| 1621 | + → PROFILE_PARAM_VALUE_ORIGIN is never written into new_dict |
| 1622 | + - control parameter also has 'aggregates' prop |
| 1623 | + → pop(PROFILE_PARAM_VALUE_ORIGIN) is called on new_dict |
| 1624 | + → KeyError on unpatched code, clean exit on patched code |
| 1625 | +
|
| 1626 | + This test does NOT rely on fixture data and will fail |
| 1627 | + deterministically against the unpatched code. |
| 1628 | + """ |
| 1629 | + |
| 1630 | + param_id = 'ac-1_prm_1' |
| 1631 | + |
| 1632 | + # profile set-parameter: AGGREGATES prop, no param-value-origin |
| 1633 | + # → prof_param_value_origin == '' → enters else branch |
| 1634 | + # → AGGREGATES in param.props → PROFILE_PARAM_VALUE_ORIGIN skipped |
| 1635 | + profile_param = Parameter1(id=param_id, props=[Property(name=const.AGGREGATES, value=const.AGGREGATES)]) |
| 1636 | + profile_set_param_dict = {param_id: profile_param} |
| 1637 | + |
| 1638 | + # control parameter: AGGREGATES prop + values |
| 1639 | + # → AGGREGATES in orig_param.props → pop(PROFILE_PARAM_VALUE_ORIGIN) called |
| 1640 | + # → KeyError on unpatched code (no default), clean on patched (None default) |
| 1641 | + control_param = Parameter1( |
| 1642 | + id=param_id, props=[Property(name=const.AGGREGATES, value=const.AGGREGATES)], values=['some-value'] |
| 1643 | + ) |
| 1644 | + control_param_dict = {param_id: control_param} |
| 1645 | + |
| 1646 | + context = ControlContext.generate( |
| 1647 | + purpose=ContextPurpose.SSP, to_markdown=True, trestle_root=tmp_path, md_root=tmp_path |
| 1648 | + ) |
| 1649 | + |
| 1650 | + writer = CatalogWriter(Mock(spec=CatalogInterface)) |
| 1651 | + |
| 1652 | + try: |
| 1653 | + result = writer._construct_set_parameters_dict(profile_set_param_dict, control_param_dict, context) |
| 1654 | + except KeyError as e: |
| 1655 | + raise AssertionError( |
| 1656 | + f'KeyError raised for key {e} — regression: unpatched code path hit. ' |
| 1657 | + f'Ensure dict.pop(const.PROFILE_PARAM_VALUE_ORIGIN, None) is applied.' |
| 1658 | + ) from e |
| 1659 | + |
| 1660 | + param_result = result.get(param_id, {}) |
| 1661 | + assert const.PROFILE_PARAM_VALUE_ORIGIN not in param_result, ( |
| 1662 | + 'profile-param-value-origin must be absent for params with aggregates prop' |
| 1663 | + ) |
| 1664 | + |
| 1665 | + |
| 1666 | +def test_construct_set_parameters_dict_aggregates_with_prof_origin_ssp(tmp_path: pathlib.Path) -> None: |
| 1667 | + """Regression test: KeyError when prof_param_value_origin is set + purpose=SSP + AGGREGATES. |
| 1668 | +
|
| 1669 | + Unpatched code path: |
| 1670 | + - prof_param_value_origin != '' → enters first if branch |
| 1671 | + - purpose == SSP (not PROFILE) → inner if skipped |
| 1672 | + - PROFILE_PARAM_VALUE_ORIGIN never added to new_dict |
| 1673 | + - AGGREGATES in control param → pop → KeyError on unpatched code |
| 1674 | + """ |
| 1675 | + |
| 1676 | + param_id = 'ac-1_prm_1' |
| 1677 | + |
| 1678 | + # profile param: has param-value-origin (not empty) + AGGREGATES prop |
| 1679 | + # → prof_param_value_origin != '' → first branch |
| 1680 | + # → purpose=SSP → inner if (purpose==PROFILE) skipped |
| 1681 | + # → PROFILE_PARAM_VALUE_ORIGIN not added |
| 1682 | + profile_param = Parameter1( |
| 1683 | + id=param_id, |
| 1684 | + props=[ |
| 1685 | + Property(name=const.AGGREGATES, value=const.AGGREGATES), |
| 1686 | + Property(name='param-value-origin', value='OCISO'), |
| 1687 | + ], |
| 1688 | + ) |
| 1689 | + profile_set_param_dict = {param_id: profile_param} |
| 1690 | + |
| 1691 | + # control param: AGGREGATES prop → pop(PROFILE_PARAM_VALUE_ORIGIN) called |
| 1692 | + control_param = Parameter1( |
| 1693 | + id=param_id, props=[Property(name=const.AGGREGATES, value=const.AGGREGATES)], values=['some-value'] |
| 1694 | + ) |
| 1695 | + control_param_dict = {param_id: control_param} |
| 1696 | + |
| 1697 | + context = ControlContext.generate( |
| 1698 | + purpose=ContextPurpose.SSP, to_markdown=True, trestle_root=tmp_path, md_root=tmp_path |
| 1699 | + ) |
| 1700 | + |
| 1701 | + writer = CatalogWriter(Mock(spec=CatalogInterface)) |
| 1702 | + |
| 1703 | + try: |
| 1704 | + result = writer._construct_set_parameters_dict(profile_set_param_dict, control_param_dict, context) |
| 1705 | + except KeyError as e: |
| 1706 | + raise AssertionError( |
| 1707 | + f'KeyError raised for {e}: prof_param_value_origin set + SSP purpose + AGGREGATES path not handled.' |
| 1708 | + ) from e |
| 1709 | + |
| 1710 | + param_result = result.get(param_id, {}) |
| 1711 | + assert const.PROFILE_PARAM_VALUE_ORIGIN not in param_result |
| 1712 | + |
| 1713 | + |
| 1714 | +def test_construct_set_parameters_dict_aggregates_with_prof_origin_profile_purpose(tmp_path: pathlib.Path) -> None: |
| 1715 | + """Regression test: KeyError when prof_param_value_origin set + purpose=PROFILE + AGGREGATES in both. |
| 1716 | +
|
| 1717 | + Unpatched code path: |
| 1718 | + - prof_param_value_origin != '' → enters first if branch |
| 1719 | + - purpose == PROFILE → inner if entered |
| 1720 | + - AGGREGATES in profile param.props → skip adding PROFILE_PARAM_VALUE_ORIGIN |
| 1721 | + - AGGREGATES in control param → pop → KeyError on unpatched code |
| 1722 | + """ |
| 1723 | + |
| 1724 | + param_id = 'ac-1_prm_1' |
| 1725 | + |
| 1726 | + # profile param: has param-value-origin + AGGREGATES |
| 1727 | + # → prof_param_value_origin != '' + purpose==PROFILE + AGGREGATES in props |
| 1728 | + # → PROFILE_PARAM_VALUE_ORIGIN not added |
| 1729 | + profile_param = Parameter1( |
| 1730 | + id=param_id, |
| 1731 | + props=[ |
| 1732 | + Property(name=const.AGGREGATES, value=const.AGGREGATES), |
| 1733 | + Property(name='param-value-origin', value='OCISO'), |
| 1734 | + ], |
| 1735 | + ) |
| 1736 | + profile_set_param_dict = {param_id: profile_param} |
| 1737 | + |
| 1738 | + # control param: AGGREGATES → pop called |
| 1739 | + control_param = Parameter1( |
| 1740 | + id=param_id, props=[Property(name=const.AGGREGATES, value=const.AGGREGATES)], values=['some-value'] |
| 1741 | + ) |
| 1742 | + control_param_dict = {param_id: control_param} |
| 1743 | + |
| 1744 | + context = ControlContext.generate( |
| 1745 | + purpose=ContextPurpose.PROFILE, to_markdown=True, trestle_root=tmp_path, md_root=tmp_path |
| 1746 | + ) |
| 1747 | + |
| 1748 | + writer = CatalogWriter(Mock(spec=CatalogInterface)) |
| 1749 | + |
| 1750 | + try: |
| 1751 | + result = writer._construct_set_parameters_dict(profile_set_param_dict, control_param_dict, context) |
| 1752 | + except KeyError as e: |
| 1753 | + raise AssertionError( |
| 1754 | + f'KeyError raised for {e}: prof_param_value_origin set + PROFILE purpose + AGGREGATES path not handled.' |
| 1755 | + ) from e |
| 1756 | + |
| 1757 | + param_result = result.get(param_id, {}) |
| 1758 | + assert const.PROFILE_PARAM_VALUE_ORIGIN not in param_result |
0 commit comments