import hashlib import asyncio import json from pathlib import Path from gfa_core.mas.tools.stage_identity import resolve_stage_identity from gfa_core.mas.tools.stage_status import get_stage_status def _artifact(workspace: Path, path: Path, role: str, sample_id: str = "") -> dict: path.write_bytes(b"proof") item = {"role": role, "path": str(path.relative_to(workspace)), "size": path.stat().st_size, "size_bytes": path.stat().st_size, "sha256": hashlib.sha256(path.read_bytes()).hexdigest()} if sample_id: item["sample_id"] = sample_id return item def test_wgs_qc_status_is_unscoped_and_rejects_partial_sample_roles(tmp_path: Path) -> None: workspace = tmp_path / "workspace" / "job" final = workspace / "final" final.mkdir(parents=True) artifacts = {"wgs_sample_config": _artifact(workspace, final / "config.yaml", "wgs_sample_config")} for role in ("clean_reads_r1", "clean_reads_r2", "fastp_html", "fastp_json"): artifacts[f"{role}.S1"] = _artifact(workspace, final / f"S1.{role}", role, "S1") manifest = {"stage": "wgs_qc", "prefix": "Cohort", "status": "success", "wgs_sample_set_id": "samples", "wgs_analysis_id": "analysis", "artifacts": artifacts, "metrics": {"input_sample_ids": ["S1"], "output_sample_ids": ["S1"]}} (final / "Cohort.wgs_qc.manifest.json").write_text(json.dumps(manifest), encoding="utf-8") identity = resolve_stage_identity("wgs_qc", "Cohort", {"scopeId": "wrong.scope"}, "job", tmp_path / "workspace") assert identity.scope_id == "" assert get_stage_status("job", "Cohort", "wgs_qc", tmp_path / "workspace", wgs_sample_set_id="samples", wgs_analysis_id="analysis").completed del artifacts["fastp_json.S1"] (final / "Cohort.wgs_qc.manifest.json").write_text(json.dumps(manifest), encoding="utf-8") assert not get_stage_status("job", "Cohort", "wgs_qc", tmp_path / "workspace", wgs_sample_set_id="samples", wgs_analysis_id="analysis").completed def test_wgs_results_fail_closed_to_exact_sample_and_analysis_identity(tmp_path: Path, monkeypatch) -> None: from apps.web.vue3 import main as web_main workspace_root = tmp_path / "workspace" final = workspace_root / "job" / "final" final.mkdir(parents=True) matched = final / "matched.fastp.html" sibling = final / "sibling.fastp.html" matched.write_text("matched", encoding="utf-8") sibling.write_text("sibling", encoding="utf-8") for name, sample_set_id, analysis_id, artifact in ( ("matched.wgs_qc.manifest.json", "sample-set-a", "analysis-a", matched), ("sibling.wgs_qc.manifest.json", "sample-set-b", "analysis-b", sibling), ): (final / name).write_text(json.dumps({ "stage": "wgs_qc", "status": "success", "prefix": "Cohort", "wgs_sample_set_id": sample_set_id, "wgs_analysis_id": analysis_id, "artifacts": {"fastp_html.S1": {"role": "fastp_html", "sample_id": "S1", "path": f"final/{artifact.name}"}}, }), encoding="utf-8") monkeypatch.setattr(web_main, "WORKSPACE_ROOT", workspace_root) result = asyncio.run(web_main.get_results( job_id="job", stage="wgs_qc", wgs_sample_set_id="sample-set-a", wgs_analysis_id="analysis-a", )) rendered = json.dumps(result, sort_keys=True) assert "matched.fastp.html" in rendered assert "sibling.fastp.html" not in rendered assert result["wgs_qc"]["wgs_sample_set_id"] == "sample-set-a" assert result["wgs_qc"]["wgs_analysis_id"] == "analysis-a" missing_identity = asyncio.run(web_main.get_results(job_id="job", stage="wgs_qc")) assert "matched.fastp.html" not in json.dumps(missing_identity, sort_keys=True) assert "sibling.fastp.html" not in json.dumps(missing_identity, sort_keys=True) def test_wgs_align_status_and_results_require_all_three_identities_and_complete_sample_roles(tmp_path: Path, monkeypatch) -> None: from apps.web.vue3 import main as web_main workspace_root = tmp_path / "workspace" workspace = workspace_root / "job" final = workspace / "final"; final.mkdir(parents=True) artifacts = {} for sample in ("S1", "S2", "S3"): for role, suffix in (("sorted_bam", "bam"), ("sorted_bam_index", "bam.bai"), ("align_log", "log")): artifacts[f"{role}.{sample}"] = _artifact(workspace, final / f"{sample}.{suffix}", role, sample) manifest = {"stage": "wgs_align", "prefix": "Cohort", "status": "success", "wgs_sample_set_id": "samples", "wgs_reference_id": "reference", "wgs_analysis_id": "analysis", "metrics": {"input_sample_ids": ["S1", "S2", "S3"], "output_sample_ids": ["S1", "S2", "S3"]}, "artifacts": artifacts} authority = final / "Cohort.wgs_align.manifest.json"; authority.write_text(json.dumps(manifest)) complete_status = get_stage_status("job", "Cohort", "wgs_align", workspace_root, wgs_sample_set_id="samples", wgs_reference_id="reference", wgs_analysis_id="analysis") assert complete_status.completed status_payload = complete_status.to_dict() assert status_payload["wgs_sample_set_id"] == "samples" assert status_payload["wgs_reference_id"] == "reference" assert status_payload["wgs_analysis_id"] == "analysis" monkeypatch.setattr(web_main, "WORKSPACE_ROOT", workspace_root) result = asyncio.run(web_main.get_results(job_id="job", stage="wgs_align", wgs_sample_set_id="samples", wgs_reference_id="reference", wgs_analysis_id="analysis")) assert "S1.bam" in json.dumps(result) shadow = final / "shadow.wgs_align.manifest.json" shadow_artifact = final / "shadow.bam"; shadow_artifact.write_bytes(b"shadow") shadow_manifest = dict(manifest) shadow_manifest["artifacts"] = { "sorted_bam.shadow": _artifact(workspace, shadow_artifact, "sorted_bam", "shadow") } shadow.write_text(json.dumps(shadow_manifest), encoding="utf-8") authoritative = asyncio.run(web_main.get_results( job_id="job", stage="wgs_align", wgs_sample_set_id="samples", wgs_reference_id="reference", wgs_analysis_id="analysis", )) assert "shadow.bam" not in json.dumps(authoritative) assert authoritative["wgs_align"]["status"] == "success" assert not authoritative["wgs_align"].get("error") (final / "S1.bam").write_bytes(b"other") tampered_status = get_stage_status("job", "Cohort", "wgs_align", workspace_root, wgs_sample_set_id="samples", wgs_reference_id="reference", wgs_analysis_id="analysis") assert not tampered_status.completed tampered = asyncio.run(web_main.get_results( job_id="job", stage="wgs_align", wgs_sample_set_id="samples", wgs_reference_id="reference", wgs_analysis_id="analysis", )) assert "S1.bam" not in json.dumps(tampered) (final / "S1.bam").write_bytes(b"proof") missing = asyncio.run(web_main.get_results(job_id="job", stage="wgs_align", wgs_sample_set_id="samples", wgs_reference_id="reference")) assert "S1.bam" not in json.dumps(missing) manifest["artifacts"].pop("sorted_bam_index.S2") authority.write_text(json.dumps(manifest)) assert not get_stage_status("job", "Cohort", "wgs_align", workspace_root, wgs_sample_set_id="samples", wgs_reference_id="reference", wgs_analysis_id="analysis").completed invalid = asyncio.run(web_main.get_results( job_id="job", stage="wgs_align", wgs_sample_set_id="samples", wgs_reference_id="reference", wgs_analysis_id="analysis", )) assert "S1.bam" not in json.dumps(invalid)