Add finding review states, ECO export, and a report release signature.
Accepted findings become ECO rows with a required reason; false-positive and wontfix stay off the change list. Pipeline re-runs keep dispositions by finding_id like comments. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
"""Wave H — finding review state, ECO, release signature.
|
||||
|
||||
Favor: false_positive/accepted/wontfix with a reason persist; accepted
|
||||
rows land in eco.json; signature hashes the findings payload.
|
||||
Against: empty reason; unknown state; false_positive is not an ECO row;
|
||||
unsigned report has no release block.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.pinscopex.models import Finding
|
||||
from backend.pinscopex.review_workflow import (
|
||||
ReviewError,
|
||||
apply_review_state,
|
||||
build_eco,
|
||||
eco_csv,
|
||||
sign_report,
|
||||
)
|
||||
|
||||
|
||||
def _finding(**kwargs):
|
||||
defaults = dict(
|
||||
finding_id="U1-001",
|
||||
designator="U1",
|
||||
mpn="PART",
|
||||
aspect="decoupling",
|
||||
finding="missing cap",
|
||||
why="no 100nF on VDD",
|
||||
status="ERROR",
|
||||
recommendation="add 100nF",
|
||||
rule_id="PS-DEC-001",
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return Finding(**defaults)
|
||||
|
||||
|
||||
def test_false_positive_requires_reason():
|
||||
with pytest.raises(ReviewError):
|
||||
apply_review_state({}, "U1-001", state="false_positive", reason=" ", user_id="local")
|
||||
|
||||
|
||||
def test_unknown_state_is_rejected():
|
||||
with pytest.raises(ReviewError):
|
||||
apply_review_state({}, "U1-001", state="fixed", reason="ok", user_id="local")
|
||||
|
||||
|
||||
def test_accepted_with_reason_is_stored():
|
||||
states = apply_review_state(
|
||||
{}, "U1-001", state="accepted", reason="will spin ECO-12", user_id="local",
|
||||
user_name="Michele",
|
||||
)
|
||||
assert states["U1-001"]["state"] == "accepted"
|
||||
assert states["U1-001"]["reason"] == "will spin ECO-12"
|
||||
assert states["U1-001"]["user_id"] == "local"
|
||||
|
||||
|
||||
def test_eco_includes_accepted_not_false_positive():
|
||||
findings = [
|
||||
_finding(finding_id="U1-001"),
|
||||
_finding(finding_id="U2-001", designator="U2", finding="noise"),
|
||||
]
|
||||
states = apply_review_state({}, "U1-001", state="accepted", reason="add cap", user_id="a")
|
||||
states = apply_review_state(states, "U2-001", state="false_positive", reason="ok in app", user_id="a")
|
||||
eco = build_eco(findings, states)
|
||||
assert [row["finding_id"] for row in eco] == ["U1-001"]
|
||||
assert eco[0]["rule_id"] == "PS-DEC-001"
|
||||
assert eco[0]["ref"] == "U1"
|
||||
assert "100nF" in eco[0]["after"]
|
||||
csv = eco_csv(eco)
|
||||
assert "U1-001" in csv
|
||||
assert "U2-001" not in csv
|
||||
|
||||
|
||||
def test_open_and_wontfix_are_not_eco_rows():
|
||||
findings = [_finding()]
|
||||
states = apply_review_state({}, "U1-001", state="wontfix", reason="wont ship", user_id="a")
|
||||
assert build_eco(findings, states) == []
|
||||
assert build_eco(findings, {}) == []
|
||||
|
||||
|
||||
def test_signature_changes_when_findings_change():
|
||||
a = sign_report({"findings": [{"finding_id": "U1-001"}]}, user_id="local")
|
||||
b = sign_report({"findings": [{"finding_id": "U1-002"}]}, user_id="local")
|
||||
assert a["user_id"] == "local"
|
||||
assert a["sha256"] != b["sha256"]
|
||||
assert a["timestamp"]
|
||||
|
||||
|
||||
def _client(tmp_path):
|
||||
from fastapi.testclient import TestClient
|
||||
from backend.main import app
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
app.state.storage = LocalStorageBackend(tmp_path)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _seed_report(client, findings):
|
||||
meta = client.post("/api/projects", json={"name": "board"}).json()
|
||||
pid = meta["id"]
|
||||
storage = client.app.state.storage
|
||||
prefix = f"users/local/projects/{pid}"
|
||||
storage.write_json(f"{prefix}/report.json", {
|
||||
"project": "board",
|
||||
"timestamp": "2026-01-01T00:00:00+00:00",
|
||||
"findings": [f.model_dump() for f in findings],
|
||||
"summary": {"total": len(findings), "ERROR": 1, "WARNING": 0, "INFO": 0},
|
||||
})
|
||||
return pid
|
||||
|
||||
|
||||
def test_api_review_without_reason_is_400(tmp_path):
|
||||
client = _client(tmp_path)
|
||||
pid = _seed_report(client, [_finding()])
|
||||
res = client.put(f"/api/report/{pid}/findings/U1-001/review", json={
|
||||
"state": "accepted", "reason": "",
|
||||
})
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
def test_api_review_and_eco(tmp_path):
|
||||
client = _client(tmp_path)
|
||||
pid = _seed_report(client, [_finding()])
|
||||
res = client.put(f"/api/report/{pid}/findings/U1-001/review", json={
|
||||
"state": "accepted", "reason": "add 100nF near U1.3",
|
||||
})
|
||||
assert res.status_code == 200
|
||||
report = client.get(f"/api/report/{pid}").json()
|
||||
assert report["review_states"]["U1-001"]["state"] == "accepted"
|
||||
eco = client.get(f"/api/report/{pid}/eco.json").json()
|
||||
assert eco["items"][0]["finding_id"] == "U1-001"
|
||||
csv = client.get(f"/api/report/{pid}/eco.csv")
|
||||
assert csv.status_code == 200
|
||||
assert "U1-001" in csv.text
|
||||
|
||||
|
||||
def test_api_unknown_finding_is_404(tmp_path):
|
||||
client = _client(tmp_path)
|
||||
pid = _seed_report(client, [_finding()])
|
||||
res = client.put(f"/api/report/{pid}/findings/NOPE/review", json={
|
||||
"state": "accepted", "reason": "x",
|
||||
})
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
def test_api_sign_release(tmp_path):
|
||||
client = _client(tmp_path)
|
||||
pid = _seed_report(client, [_finding()])
|
||||
res = client.post(f"/api/report/{pid}/sign")
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert len(body["sha256"]) == 64
|
||||
report = client.get(f"/api/report/{pid}").json()
|
||||
assert report["release"]["sha256"] == body["sha256"]
|
||||
assert report["release"]["user_id"] == "local"
|
||||
Reference in New Issue
Block a user