a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
236 lines
7.6 KiB
Go
236 lines
7.6 KiB
Go
package analysis
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// sampleRisk is a synthetic PR-risk result carrying values that would otherwise
|
|
// require a live graph — the receipt is a pure projection, so it can be fed a
|
|
// hand-built result directly.
|
|
func sampleRisk() PRRiskResult {
|
|
return PRRiskResult{
|
|
Score: 68.5,
|
|
Risk: RiskHigh,
|
|
Factors: []PRRiskFactor{
|
|
{Axis: "flow", Score: 72.0, Reason: "wide fan-in on internal/auth/login.go::ValidateToken"},
|
|
{Axis: "coverage", Score: 61.0, Reason: "3 changed symbols with no covering test"},
|
|
},
|
|
TotalAffected: 37,
|
|
UncoveredSymbols: 4,
|
|
CommunitySpan: 3,
|
|
SecurityHits: []string{"auth"},
|
|
}
|
|
}
|
|
|
|
func TestBuildReviewReceipt_ProjectsNamedFields(t *testing.T) {
|
|
r := BuildReviewReceipt(sampleRisk(), "PENDING", false, false)
|
|
|
|
if r.ReceiptVersion != 1 {
|
|
t.Fatalf("receipt_version = %d, want 1", r.ReceiptVersion)
|
|
}
|
|
if r.RiskTier != RiskHigh {
|
|
t.Fatalf("risk_tier = %q, want HIGH", r.RiskTier)
|
|
}
|
|
if r.AffectedCount != 37 {
|
|
t.Fatalf("affected_count = %d, want 37", r.AffectedCount)
|
|
}
|
|
if r.UncoveredCount != 4 {
|
|
t.Fatalf("uncovered_count = %d, want 4", r.UncoveredCount)
|
|
}
|
|
if r.CommunitySpan != 3 {
|
|
t.Fatalf("community_span = %d, want 3", r.CommunitySpan)
|
|
}
|
|
if !r.SecurityFlagged {
|
|
t.Fatalf("security_flagged = false, want true (auth hit)")
|
|
}
|
|
// uncovered>0 is the most urgent action and outranks span/security.
|
|
if r.NextSafeAction != actionAddTests {
|
|
t.Fatalf("next_safe_action = %q, want %q", r.NextSafeAction, actionAddTests)
|
|
}
|
|
// HIGH (not top tier) + PENDING CI + no breaking flag => not a blocker.
|
|
if r.MergeBlocker {
|
|
t.Fatalf("merge_blocker = true, want false for HIGH+PENDING")
|
|
}
|
|
if r.BlockerReason != "" {
|
|
t.Fatalf("blocker_reason = %q, want empty for a non-blocked receipt", r.BlockerReason)
|
|
}
|
|
if len(r.TopFactors) != 2 {
|
|
t.Fatalf("top_factors len = %d, want 2", len(r.TopFactors))
|
|
}
|
|
if r.TopFactors[0].Axis != "flow" || r.TopFactors[0].Score != 72.0 {
|
|
t.Fatalf("top_factors[0] = %+v, want {flow 72}", r.TopFactors[0])
|
|
}
|
|
}
|
|
|
|
func TestNextSafeAction_Table(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in PRRiskResult
|
|
want string
|
|
}{
|
|
{"uncovered wins over span and security",
|
|
PRRiskResult{UncoveredSymbols: 1, CommunitySpan: 5, SecurityHits: []string{"auth"}},
|
|
actionAddTests},
|
|
{"high span when fully covered",
|
|
PRRiskResult{UncoveredSymbols: 0, CommunitySpan: 3},
|
|
actionSplitPR},
|
|
{"security when covered and span-local",
|
|
PRRiskResult{UncoveredSymbols: 0, CommunitySpan: 1, SecurityHits: []string{"token"}},
|
|
actionReviewSecurity},
|
|
{"merge-ready when clean",
|
|
PRRiskResult{UncoveredSymbols: 0, CommunitySpan: 1},
|
|
actionMergeReady},
|
|
{"span below threshold falls through to merge-ready",
|
|
PRRiskResult{UncoveredSymbols: 0, CommunitySpan: 2},
|
|
actionMergeReady},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := nextSafeAction(tc.in); got != tc.want {
|
|
t.Fatalf("nextSafeAction = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMergeBlocker_TopTierOrCIFailure(t *testing.T) {
|
|
// CRITICAL is the top tier: a blocker regardless of CI.
|
|
crit := sampleRisk()
|
|
crit.Risk = RiskCritical
|
|
if r := BuildReviewReceipt(crit, "SUCCESS", false, false); !r.MergeBlocker {
|
|
t.Fatalf("merge_blocker = false for CRITICAL, want true")
|
|
} else if r.BlockerReason != blockerCriticalRisk {
|
|
t.Fatalf("blocker_reason = %q, want %q", r.BlockerReason, blockerCriticalRisk)
|
|
}
|
|
|
|
// Any tier below the top is NOT a blocker on its own.
|
|
for _, tier := range []RiskLevel{RiskLow, RiskMedium, RiskHigh} {
|
|
in := sampleRisk()
|
|
in.Risk = tier
|
|
if r := BuildReviewReceipt(in, "SUCCESS", false, false); r.MergeBlocker {
|
|
t.Fatalf("merge_blocker = true for %s+SUCCESS, want false (only top tier blocks)", tier)
|
|
}
|
|
}
|
|
|
|
// CI=FAILURE blocks even a LOW-risk PR.
|
|
low := sampleRisk()
|
|
low.Risk = RiskLow
|
|
if r := BuildReviewReceipt(low, "FAILURE", false, false); !r.MergeBlocker {
|
|
t.Fatalf("merge_blocker = false for CI FAILURE, want true")
|
|
} else if r.BlockerReason != blockerCIFailure {
|
|
t.Fatalf("blocker_reason = %q, want %q", r.BlockerReason, blockerCIFailure)
|
|
}
|
|
|
|
// An out-of-band breaking-change flag blocks too.
|
|
if r := BuildReviewReceipt(low, "SUCCESS", true, false); !r.MergeBlocker {
|
|
t.Fatalf("merge_blocker = false for breaking flag, want true")
|
|
} else if r.BlockerReason != blockerBreakingChange {
|
|
t.Fatalf("blocker_reason = %q, want %q", r.BlockerReason, blockerBreakingChange)
|
|
}
|
|
}
|
|
|
|
func TestBuildReviewReceipt_ScrubRemovesContextKeepsVerdict(t *testing.T) {
|
|
// A result whose factor reasons embed a path, a symbol ID, and an email —
|
|
// the kinds of context a shared receipt must never leak.
|
|
in := sampleRisk()
|
|
in.Factors = []PRRiskFactor{
|
|
{Axis: "internal/auth/login.go::ValidateToken", Score: 72.0, Reason: "owner me@zzet.org"},
|
|
{Axis: "flow", Score: 61.0, Reason: "changed pkg/x.go::Foo"},
|
|
}
|
|
in.SecurityHits = []string{"auth"}
|
|
|
|
r := BuildReviewReceipt(in, "PENDING", false, true)
|
|
|
|
// The tier and the decision survive scrubbing.
|
|
if r.RiskTier != RiskHigh {
|
|
t.Fatalf("scrub dropped risk_tier: %q", r.RiskTier)
|
|
}
|
|
if r.NextSafeAction != actionAddTests {
|
|
t.Fatalf("scrub dropped next_safe_action: %q", r.NextSafeAction)
|
|
}
|
|
|
|
// Serialize the whole receipt and assert ZERO path-like / ::-bearing /
|
|
// email-like values leak anywhere in the JSON.
|
|
blob, err := json.Marshal(r)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
s := string(blob)
|
|
if strings.Contains(s, "::") {
|
|
t.Fatalf("scrubbed receipt leaks a symbol ID separator: %s", s)
|
|
}
|
|
if strings.Contains(s, "@") {
|
|
t.Fatalf("scrubbed receipt leaks an email: %s", s)
|
|
}
|
|
for _, frag := range []string{".go", "internal/auth", "pkg/x", "me@zzet.org", "ValidateToken"} {
|
|
if strings.Contains(s, frag) {
|
|
t.Fatalf("scrubbed receipt leaks %q: %s", frag, s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReviewReceipt_JSONIsSnakeCaseAndStable(t *testing.T) {
|
|
r := BuildReviewReceipt(sampleRisk(), "FAILURE", false, false)
|
|
blob, err := json.Marshal(r)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
|
|
// Every expected snake_case key must be present.
|
|
var generic map[string]json.RawMessage
|
|
if err := json.Unmarshal(blob, &generic); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
wantKeys := []string{
|
|
"receipt_version", "risk_tier", "next_safe_action", "merge_blocker",
|
|
"blocker_reason", "affected_count", "uncovered_count", "community_span",
|
|
"security_flagged", "top_factors",
|
|
}
|
|
for _, k := range wantKeys {
|
|
if _, ok := generic[k]; !ok {
|
|
t.Fatalf("missing snake_case key %q in %s", k, blob)
|
|
}
|
|
}
|
|
if len(generic) != len(wantKeys) {
|
|
t.Fatalf("unexpected key count: got %d keys %v, want %d", len(generic), generic, len(wantKeys))
|
|
}
|
|
|
|
// No camelCase / PascalCase keys slipped through.
|
|
for k := range generic {
|
|
if strings.ToLower(k) != k {
|
|
t.Fatalf("key %q is not snake_case", k)
|
|
}
|
|
}
|
|
|
|
// top_factors entries are themselves snake_case (axis/score).
|
|
var factors []map[string]json.RawMessage
|
|
if err := json.Unmarshal(generic["top_factors"], &factors); err != nil {
|
|
t.Fatalf("unmarshal top_factors: %v", err)
|
|
}
|
|
if len(factors) == 0 {
|
|
t.Fatal("top_factors must not be empty for this input")
|
|
}
|
|
for _, f := range factors {
|
|
if _, ok := f["axis"]; !ok {
|
|
t.Fatalf("top_factors entry missing axis: %v", f)
|
|
}
|
|
if _, ok := f["score"]; !ok {
|
|
t.Fatalf("top_factors entry missing score: %v", f)
|
|
}
|
|
if _, leaked := f["reason"]; leaked {
|
|
t.Fatalf("top_factors must not carry reason (privacy leak): %v", f)
|
|
}
|
|
}
|
|
|
|
// Re-marshalling produces byte-identical output (stable shape).
|
|
again, err := json.Marshal(r)
|
|
if err != nil {
|
|
t.Fatalf("re-marshal: %v", err)
|
|
}
|
|
if string(again) != string(blob) {
|
|
t.Fatalf("JSON not stable:\n %s\n %s", blob, again)
|
|
}
|
|
}
|