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>
158 lines
5.1 KiB
Python
158 lines
5.1 KiB
Python
"""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"
|