@@ -2831,6 +2831,147 @@ def stage_then_replace_receipt(target: Path, content: str) -> Path:
28312831 assert not list (output_dir .glob ("portfolio-truth-*.json" ))
28322832
28332833
2834+ def test_publish_refuses_nested_evidence_that_expires_after_snapshot (
2835+ portfolio_workspace : Path ,
2836+ portfolio_catalog : Path ,
2837+ legacy_registry : Path ,
2838+ tmp_path : Path ,
2839+ monkeypatch : pytest .MonkeyPatch ,
2840+ ) -> None :
2841+ from contextlib import contextmanager
2842+
2843+ now = datetime .now (timezone .utc ).replace (microsecond = 0 )
2844+ nested_observed_at = now - timedelta (hours = 24 ) + timedelta (milliseconds = 500 )
2845+ output_dir = tmp_path / "output"
2846+ output_dir .mkdir ()
2847+ receipt_path = output_dir / "github-security-coverage-latest.json"
2848+ receipt_truth = {
2849+ "projects" : [
2850+ {
2851+ "identity" : {"repo_full_name" : "d/Alpha" },
2852+ "derived" : {"attention_state" : "active-product" },
2853+ }
2854+ ]
2855+ }
2856+ receipt = collect_security_coverage (
2857+ receipt_truth ,
2858+ token = None ,
2859+ expected_cohort_count = 1 ,
2860+ now = now ,
2861+ producer_commit = "a" * 40 ,
2862+ )
2863+ alpha_receipt = receipt ["repositories" ]["d/Alpha" ]
2864+ alpha_receipt ["providers" ]["dependabot" ] = _provider_result (
2865+ "dependabot" ,
2866+ state = "observed" ,
2867+ observed_at = nested_observed_at .isoformat (),
2868+ http_status = 200 ,
2869+ pagination_complete = True ,
2870+ counts = {"critical" : 0 , "high" : 0 , "medium" : 0 , "low" : 0 },
2871+ )
2872+ alpha_receipt ["repository" ] = _remote_repository_result (
2873+ state = "observed" ,
2874+ observed_at = nested_observed_at .isoformat (),
2875+ default_branch = "main" ,
2876+ head_sha = "b" * 40 ,
2877+ archived = False ,
2878+ )
2879+ write_security_coverage_receipt (
2880+ receipt ,
2881+ receipt_path ,
2882+ expected_cohort_count = 1 ,
2883+ )
2884+ loaded = load_security_coverage_receipt (
2885+ receipt_path ,
2886+ expected_cohort_count = 1 ,
2887+ expected_producer_commit = "a" * 40 ,
2888+ now = now ,
2889+ )
2890+ at_boundary = load_security_coverage_receipt (
2891+ receipt_path ,
2892+ expected_cohort_count = 1 ,
2893+ expected_producer_commit = "a" * 40 ,
2894+ now = now + timedelta (milliseconds = 500 ),
2895+ )
2896+ reloaded = load_security_coverage_receipt (
2897+ receipt_path ,
2898+ expected_cohort_count = 1 ,
2899+ expected_producer_commit = "a" * 40 ,
2900+ now = now + timedelta (seconds = 1 ),
2901+ )
2902+ assert loaded .receipt_state == at_boundary .receipt_state == "fresh"
2903+ assert reloaded .receipt_state == "fresh"
2904+ assert loaded .entries_by_full_name ["d/Alpha" ]["providers" ]["dependabot" ][
2905+ "state"
2906+ ] == "observed"
2907+ assert reloaded .entries_by_full_name ["d/Alpha" ]["providers" ]["dependabot" ][
2908+ "state"
2909+ ] == "stale"
2910+ assert at_boundary .entries_by_full_name ["d/Alpha" ]["providers" ]["dependabot" ][
2911+ "state"
2912+ ] == "observed"
2913+ assert loaded .entries_by_full_name ["d/Alpha" ]["repository" ]["state" ] == (
2914+ "observed"
2915+ )
2916+ assert reloaded .entries_by_full_name ["d/Alpha" ]["repository" ]["state" ] == (
2917+ "stale"
2918+ )
2919+ assert at_boundary .entries_by_full_name ["d/Alpha" ]["repository" ]["state" ] == (
2920+ "observed"
2921+ )
2922+
2923+ binding = loaded .binding ()
2924+ metadata = {
2925+ "source_id" : "github-security-coverage-receipt" ,
2926+ "schema_version" : loaded .schema_version ,
2927+ "produced_at" : loaded .produced_at ,
2928+ "state" : loaded .receipt_state ,
2929+ "age_hours" : loaded .age_hours ,
2930+ "producer_commit" : loaded .producer_commit ,
2931+ "cohort_policy" : loaded .cohort_policy ,
2932+ "cohort_repository_count" : len (loaded .cohort_repositories ),
2933+ "path" : loaded .source_path ,
2934+ "receipt_id" : loaded .receipt_id ,
2935+ "content_sha256" : loaded .content_sha256 ,
2936+ }
2937+ registry_output = portfolio_workspace / "project-registry.md"
2938+ report_output = portfolio_workspace / "PORTFOLIO-AUDIT-REPORT.md"
2939+ registry_output .write_text ("sentinel-registry\n " )
2940+ report_output .write_text ("sentinel-report\n " )
2941+
2942+ @contextmanager
2943+ def reloaded_guard (_binding : SecurityCoverageReceiptBinding ):
2944+ yield reloaded
2945+
2946+ monkeypatch .setattr (
2947+ "src.portfolio_truth_publish.verified_security_coverage_receipt_binding" ,
2948+ reloaded_guard ,
2949+ )
2950+
2951+ with pytest .raises (
2952+ PortfolioTruthPublishError ,
2953+ match = "normalized evidence changed after it was loaded" ,
2954+ ):
2955+ publish_portfolio_truth (
2956+ workspace_root = portfolio_workspace ,
2957+ output_dir = output_dir ,
2958+ registry_output = registry_output ,
2959+ portfolio_report_output = report_output ,
2960+ catalog_path = portfolio_catalog ,
2961+ legacy_registry_path = legacy_registry ,
2962+ include_notion = False ,
2963+ security_alerts_by_name = loaded .entries_by_full_name ,
2964+ security_coverage_metadata = metadata ,
2965+ security_receipt_binding = binding ,
2966+ now = now ,
2967+ )
2968+
2969+ assert registry_output .read_text () == "sentinel-registry\n "
2970+ assert report_output .read_text () == "sentinel-report\n "
2971+ assert not (output_dir / "portfolio-truth-latest.json" ).exists ()
2972+ assert not list (output_dir .glob ("portfolio-truth-*.json" ))
2973+
2974+
28342975def test_publish_requires_producer_evidence_before_touching_outputs (
28352976 portfolio_workspace : Path ,
28362977 portfolio_catalog : Path ,
0 commit comments