package handlers import ( "testing" ) func TestComputeLayerDiff_IdenticalLayers(t *testing.T) { layers := []LayerDetail{ {Index: 1, Digest: "sha256:aaa", Size: 100, Command: "ADD file in /"}, {Index: 2, Digest: "sha256:bbb", Size: 200, Command: "RUN apt-get update"}, } diff := computeLayerDiff(layers, layers) if len(diff) != 2 { t.Fatalf("expected 2 entries, got %d", len(diff)) } for _, e := range diff { if e.Status != "shared" { t.Errorf("expected shared, got %s", e.Status) } } } func TestComputeLayerDiff_SharedPrefixThenDivergence(t *testing.T) { from := []LayerDetail{ {Index: 1, Digest: "sha256:base", Size: 100}, {Index: 2, Digest: "sha256:old", Size: 200}, } to := []LayerDetail{ {Index: 1, Digest: "sha256:base", Size: 100}, {Index: 2, Digest: "sha256:new1", Size: 300}, {Index: 3, Digest: "sha256:new2", Size: 150}, } diff := computeLayerDiff(from, to) // Lockstep: shared, then -/+ pair (no command match), then +1 added if len(diff) != 4 { t.Fatalf("expected 4 entries, got %d", len(diff)) } expected := []struct { status string digest string }{ {"shared", "sha256:base"}, {"removed", "sha256:old"}, // no command, different digest → -/+ {"added", "sha256:new1"}, {"added", "sha256:new2"}, // extra layer in to } for i, e := range expected { if diff[i].Status != e.status { t.Errorf("[%d] expected status %s, got %s", i, e.status, diff[i].Status) } if diff[i].Layer.Digest != e.digest { t.Errorf("[%d] expected digest %s, got %s", i, e.digest, diff[i].Layer.Digest) } } } func TestComputeLayerDiff_SameCommandDifferentDigest(t *testing.T) { from := []LayerDetail{ {Index: 1, Digest: "sha256:base", Size: 100, Command: "ADD file in /"}, {Index: 2, Digest: "sha256:old", Size: 200, Command: "RUN apt-get update"}, {Index: 3, Digest: "sha256:old2", Size: 300, Command: "RUN pip install flask"}, } to := []LayerDetail{ {Index: 1, Digest: "sha256:base", Size: 100, Command: "ADD file in /"}, {Index: 2, Digest: "sha256:new", Size: 250, Command: "RUN apt-get update"}, {Index: 3, Digest: "sha256:new2", Size: 350, Command: "RUN pip install flask"}, } diff := computeLayerDiff(from, to) if len(diff) != 3 { t.Fatalf("expected 3 entries, got %d", len(diff)) } if diff[0].Status != "shared" { t.Errorf("[0] expected shared, got %s", diff[0].Status) } if diff[1].Status != "rebuilt" { t.Errorf("[1] expected rebuilt, got %s", diff[1].Status) } if diff[1].PrevLayer == nil || diff[1].PrevLayer.Size != 200 { t.Error("[1] expected PrevLayer with size 200") } if diff[2].Status != "rebuilt" { t.Errorf("[2] expected rebuilt, got %s", diff[2].Status) } } func TestComputeLayerDiff_DifferentCommandDifferentDigest(t *testing.T) { from := []LayerDetail{ {Index: 1, Digest: "sha256:base", Size: 100, Command: "ADD file in /"}, {Index: 2, Digest: "sha256:old", Size: 200, Command: "RUN pip install requests==2.28"}, } to := []LayerDetail{ {Index: 1, Digest: "sha256:base", Size: 100, Command: "ADD file in /"}, {Index: 2, Digest: "sha256:new", Size: 250, Command: "RUN pip install requests==2.31"}, } diff := computeLayerDiff(from, to) if len(diff) != 3 { t.Fatalf("expected 3 entries, got %d", len(diff)) } if diff[0].Status != "shared" { t.Errorf("[0] expected shared, got %s", diff[0].Status) } // Different command → -/+ pair if diff[1].Status != "removed" { t.Errorf("[1] expected removed, got %s", diff[1].Status) } if diff[2].Status != "added" { t.Errorf("[2] expected added, got %s", diff[2].Status) } } func TestComputeLayerDiff_InsertedLayer(t *testing.T) { from := []LayerDetail{ {Index: 1, Digest: "sha256:aaa", Size: 100, Command: "ADD file in /"}, {Index: 2, Digest: "sha256:bbb", Size: 200, Command: "RUN apt-get update"}, {Index: 3, Digest: "sha256:ccc", Size: 300, Command: "RUN pip install flask"}, } to := []LayerDetail{ {Index: 1, Digest: "sha256:aaa", Size: 100, Command: "ADD file in /"}, {Index: 2, Digest: "sha256:ddd", Size: 210, Command: "RUN apt-get update"}, {Index: 3, Digest: "sha256:eee", Size: 150, Command: "RUN apt-get install curl"}, {Index: 4, Digest: "sha256:fff", Size: 310, Command: "RUN pip install flask"}, } diff := computeLayerDiff(from, to) // Expected: shared, rebuilt, +added, rebuilt expected := []string{"shared", "rebuilt", "added", "rebuilt"} if len(diff) != len(expected) { t.Fatalf("expected %d entries, got %d: %v", len(expected), len(diff), diffStatuses(diff)) } for i, e := range expected { if diff[i].Status != e { t.Errorf("[%d] expected %s, got %s", i, e, diff[i].Status) } } } func TestComputeLayerDiff_RemovedLayer(t *testing.T) { from := []LayerDetail{ {Index: 1, Digest: "sha256:aaa", Size: 100, Command: "ADD file in /"}, {Index: 2, Digest: "sha256:bbb", Size: 200, Command: "RUN apt-get update"}, {Index: 3, Digest: "sha256:ccc", Size: 150, Command: "RUN apt-get install curl"}, {Index: 4, Digest: "sha256:ddd", Size: 300, Command: "RUN pip install flask"}, } to := []LayerDetail{ {Index: 1, Digest: "sha256:aaa", Size: 100, Command: "ADD file in /"}, {Index: 2, Digest: "sha256:eee", Size: 210, Command: "RUN apt-get update"}, {Index: 3, Digest: "sha256:fff", Size: 310, Command: "RUN pip install flask"}, } diff := computeLayerDiff(from, to) // Expected: shared, rebuilt, -removed, rebuilt expected := []string{"shared", "rebuilt", "removed", "rebuilt"} if len(diff) != len(expected) { t.Fatalf("expected %d entries, got %d: %v", len(expected), len(diff), diffStatuses(diff)) } for i, e := range expected { if diff[i].Status != e { t.Errorf("[%d] expected %s, got %s", i, e, diff[i].Status) } } } // helper for test error messages func diffStatuses(diff []LayerDiffEntry) []string { var s []string for _, d := range diff { s = append(s, d.Status) } return s } func TestComputeLayerDiff_EmptyLayersMatchByCommand(t *testing.T) { from := []LayerDetail{ {Index: 1, Digest: "sha256:base", Size: 100}, {Index: 0, EmptyLayer: true, Command: "ENV FOO=bar"}, {Index: 2, Digest: "sha256:old", Size: 200}, } to := []LayerDetail{ {Index: 1, Digest: "sha256:base", Size: 100}, {Index: 0, EmptyLayer: true, Command: "ENV FOO=bar"}, {Index: 2, Digest: "sha256:new", Size: 300}, } diff := computeLayerDiff(from, to) if len(diff) != 4 { t.Fatalf("expected 4 entries, got %d", len(diff)) } if diff[0].Status != "shared" || diff[1].Status != "shared" { t.Error("first two entries should be shared (base layer + empty layer)") } // Different digests, no command → -/+ pair if diff[2].Status != "removed" { t.Errorf("[2] expected removed, got %s", diff[2].Status) } if diff[3].Status != "added" { t.Errorf("[3] expected added, got %s", diff[3].Status) } } func TestComputeLayerDiff_CompletelyDifferent(t *testing.T) { from := []LayerDetail{ {Index: 1, Digest: "sha256:old1", Size: 100}, } to := []LayerDetail{ {Index: 1, Digest: "sha256:new1", Size: 200}, {Index: 2, Digest: "sha256:new2", Size: 300}, } diff := computeLayerDiff(from, to) // Lockstep: -/+ pair for position 1, then +1 added if len(diff) != 3 { t.Fatalf("expected 3 entries, got %d", len(diff)) } if diff[0].Status != "removed" { t.Errorf("[0] expected removed, got %s", diff[0].Status) } if diff[1].Status != "added" { t.Errorf("[1] expected added, got %s", diff[1].Status) } if diff[2].Status != "added" { t.Errorf("[2] expected added, got %s", diff[2].Status) } } func TestComputeLayerDiff_EmptyInputs(t *testing.T) { diff := computeLayerDiff(nil, nil) if len(diff) != 0 { t.Fatalf("expected 0 entries, got %d", len(diff)) } diff = computeLayerDiff(nil, []LayerDetail{{Index: 1, Digest: "sha256:a"}}) if len(diff) != 1 || diff[0].Status != "added" { t.Error("expected 1 added entry") } diff = computeLayerDiff([]LayerDetail{{Index: 1, Digest: "sha256:a"}}, nil) if len(diff) != 1 || diff[0].Status != "removed" { t.Error("expected 1 removed entry") } } func TestComputeVulnDiff_FixedAndNew(t *testing.T) { from := []vulnMatch{ {CVEID: "CVE-2024-001", Severity: "Critical", Package: "openssl", Version: "1.1.0"}, {CVEID: "CVE-2024-002", Severity: "High", Package: "curl", Version: "7.85"}, {CVEID: "CVE-2024-003", Severity: "Medium", Package: "zlib", Version: "1.2.11"}, } to := []vulnMatch{ {CVEID: "CVE-2024-002", Severity: "High", Package: "curl", Version: "7.85"}, {CVEID: "CVE-2025-001", Severity: "High", Package: "requests", Version: "2.31"}, } diff := computeVulnDiff(from, to) counts := map[string]int{} for _, e := range diff { counts[e.Status]++ } if counts["fixed"] != 2 { t.Errorf("expected 2 fixed, got %d", counts["fixed"]) } if counts["new"] != 1 { t.Errorf("expected 1 new, got %d", counts["new"]) } if counts["unchanged"] != 1 { t.Errorf("expected 1 unchanged, got %d", counts["unchanged"]) } } func TestComputeVulnDiff_AllFixed(t *testing.T) { from := []vulnMatch{ {CVEID: "CVE-2024-001", Severity: "Critical"}, {CVEID: "CVE-2024-002", Severity: "High"}, } diff := computeVulnDiff(from, nil) for _, e := range diff { if e.Status != "fixed" { t.Errorf("expected fixed, got %s", e.Status) } } if len(diff) != 2 { t.Errorf("expected 2, got %d", len(diff)) } } func TestComputeVulnDiff_AllNew(t *testing.T) { to := []vulnMatch{ {CVEID: "CVE-2025-001", Severity: "Critical"}, } diff := computeVulnDiff(nil, to) if len(diff) != 1 || diff[0].Status != "new" { t.Error("expected 1 new entry") } } func TestComputeVulnDiff_Empty(t *testing.T) { diff := computeVulnDiff(nil, nil) if len(diff) != 0 { t.Errorf("expected 0, got %d", len(diff)) } } func TestComputeDiffSummary(t *testing.T) { fromLayers := []LayerDetail{ {Index: 1, Size: 1000}, {Index: 2, Size: 2000}, } toLayers := []LayerDetail{ {Index: 1, Size: 1000}, {Index: 2, Size: 2500}, {Index: 3, Size: 500}, } vulnDiff := []VulnDiffEntry{ {Status: "fixed", Vuln: vulnMatch{Severity: "Critical"}}, {Status: "fixed", Vuln: vulnMatch{Severity: "High"}}, {Status: "fixed", Vuln: vulnMatch{Severity: "High"}}, {Status: "new", Vuln: vulnMatch{Severity: "Medium"}}, {Status: "unchanged", Vuln: vulnMatch{Severity: "Low"}}, } summary := computeDiffSummary(fromLayers, toLayers, vulnDiff, true, nil, false) if summary.SizeDelta != 1000 { t.Errorf("expected size delta 1000, got %d", summary.SizeDelta) } if summary.LayerCountFrom != 2 { t.Errorf("expected from count 2, got %d", summary.LayerCountFrom) } if summary.LayerCountTo != 3 { t.Errorf("expected to count 3, got %d", summary.LayerCountTo) } if summary.VulnFixedCount != 3 { t.Errorf("expected 3 fixed, got %d", summary.VulnFixedCount) } if summary.VulnNewCount != 1 { t.Errorf("expected 1 new, got %d", summary.VulnNewCount) } if summary.VulnFixedBySev.Critical != 1 { t.Errorf("expected 1 fixed critical, got %d", summary.VulnFixedBySev.Critical) } if summary.VulnFixedBySev.High != 2 { t.Errorf("expected 2 fixed high, got %d", summary.VulnFixedBySev.High) } if summary.VulnNewBySev.Medium != 1 { t.Errorf("expected 1 new medium, got %d", summary.VulnNewBySev.Medium) } if !summary.HasVulnData { t.Error("expected HasVulnData to be true") } } func TestComputeDiffSummary_NoVulnData(t *testing.T) { summary := computeDiffSummary( []LayerDetail{{Size: 100}}, []LayerDetail{{Size: 200}}, nil, false, nil, false, ) if summary.HasVulnData { t.Error("expected HasVulnData to be false") } if summary.SizeDelta != 100 { t.Errorf("expected size delta 100, got %d", summary.SizeDelta) } } func TestComputeDiffSummary_SmallerImage(t *testing.T) { summary := computeDiffSummary( []LayerDetail{{Size: 5000}, {Size: 3000}}, []LayerDetail{{Size: 2000}}, nil, false, nil, false, ) if summary.SizeDelta != -6000 { t.Errorf("expected size delta -6000, got %d", summary.SizeDelta) } if summary.LayerCountFrom != 2 || summary.LayerCountTo != 1 { t.Error("unexpected layer counts") } } // sbomStatusCounts tallies a diff result by status for compact assertions. func sbomStatusCounts(diff []SbomDiffEntry) map[string]int { counts := make(map[string]int) for _, e := range diff { counts[e.Status]++ } return counts } func TestComputeSbomDiff_Identical(t *testing.T) { pkgs := []sbomPackage{ {Name: "openssl", Version: "3.0.1", Type: "deb"}, {Name: "zlib", Version: "1.2.13", Type: "deb"}, } diff := computeSbomDiff(pkgs, pkgs) if len(diff) != 2 { t.Fatalf("expected 2 entries, got %d", len(diff)) } for _, e := range diff { if e.Status != "unchanged" { t.Errorf("expected unchanged, got %q for %s", e.Status, e.Package.Name) } } } func TestComputeSbomDiff_VersionBump(t *testing.T) { from := []sbomPackage{{Name: "openssl", Version: "3.0.1", Type: "deb"}} to := []sbomPackage{{Name: "openssl", Version: "3.0.2", Type: "deb"}} diff := computeSbomDiff(from, to) if len(diff) != 1 { t.Fatalf("expected 1 entry, got %d", len(diff)) } if diff[0].Status != "changed" { t.Errorf("expected changed, got %q", diff[0].Status) } if diff[0].PrevVersion != "3.0.1" { t.Errorf("expected PrevVersion 3.0.1, got %q", diff[0].PrevVersion) } if diff[0].Package.Version != "3.0.2" { t.Errorf("expected Package.Version 3.0.2, got %q", diff[0].Package.Version) } } func TestComputeSbomDiff_AddedRemovedEmpty(t *testing.T) { diff := computeSbomDiff(nil, nil) if len(diff) != 0 { t.Errorf("expected 0 entries for empty inputs, got %d", len(diff)) } diff = computeSbomDiff(nil, []sbomPackage{{Name: "curl", Version: "8.0", Type: "deb"}}) if len(diff) != 1 || diff[0].Status != "added" { t.Errorf("expected single added entry, got %+v", diff) } diff = computeSbomDiff([]sbomPackage{{Name: "curl", Version: "8.0", Type: "deb"}}, nil) if len(diff) != 1 || diff[0].Status != "removed" { t.Errorf("expected single removed entry, got %+v", diff) } } func TestComputeSbomDiff_SameNameDifferentType(t *testing.T) { // A deb package and a binary share a name; the deb version bump must // not pair against the binary entry. from := []sbomPackage{ {Name: "curl", Version: "8.0", Type: "deb"}, {Name: "curl", Version: "8.0", Type: ""}, } to := []sbomPackage{ {Name: "curl", Version: "8.1", Type: "deb"}, {Name: "curl", Version: "8.0", Type: ""}, } diff := computeSbomDiff(from, to) if len(diff) != 2 { t.Fatalf("expected 2 entries, got %d", len(diff)) } counts := sbomStatusCounts(diff) if counts["changed"] != 1 || counts["unchanged"] != 1 { t.Errorf("expected 1 changed + 1 unchanged, got %v", counts) } for _, e := range diff { if e.Status == "changed" { if e.Package.Type != "deb" || e.PrevVersion != "8.0" || e.Package.Version != "8.1" { t.Errorf("changed entry paired wrong: %+v", e) } } } } func TestComputeSbomDiff_Multiplicity(t *testing.T) { // from {1.0, 1.1} vs to {1.1, 1.2}: 1.1 matches exactly, 1.0 pairs // with 1.2 as a version change. from := []sbomPackage{ {Name: "libssl", Version: "1.0", Type: "deb"}, {Name: "libssl", Version: "1.1", Type: "deb"}, } to := []sbomPackage{ {Name: "libssl", Version: "1.1", Type: "deb"}, {Name: "libssl", Version: "1.2", Type: "deb"}, } diff := computeSbomDiff(from, to) if len(diff) != 2 { t.Fatalf("expected 2 entries, got %d", len(diff)) } counts := sbomStatusCounts(diff) if counts["unchanged"] != 1 || counts["changed"] != 1 { t.Errorf("expected 1 unchanged + 1 changed, got %v", counts) } for _, e := range diff { if e.Status == "changed" && (e.PrevVersion != "1.0" || e.Package.Version != "1.2") { t.Errorf("expected 1.0 to pair with 1.2, got %+v", e) } } // Asymmetric counts: two from-side versions, one to-side version. from = []sbomPackage{ {Name: "libssl", Version: "1.0", Type: "deb"}, {Name: "libssl", Version: "1.1", Type: "deb"}, } to = []sbomPackage{{Name: "libssl", Version: "1.2", Type: "deb"}} diff = computeSbomDiff(from, to) counts = sbomStatusCounts(diff) if counts["changed"] != 1 || counts["removed"] != 1 { t.Errorf("expected 1 changed + 1 removed, got %v", counts) } } func TestComputeSbomDiff_DuplicateEntriesOneSide(t *testing.T) { // Two identical entries on the from side must not both match the // single to-side entry (multiset, not set). from := []sbomPackage{ {Name: "foo", Version: "1.0", Type: "npm"}, {Name: "foo", Version: "1.0", Type: "npm"}, } to := []sbomPackage{{Name: "foo", Version: "1.0", Type: "npm"}} diff := computeSbomDiff(from, to) if len(diff) != 2 { t.Fatalf("expected 2 entries, got %d", len(diff)) } counts := sbomStatusCounts(diff) if counts["unchanged"] != 1 || counts["removed"] != 1 { t.Errorf("expected 1 unchanged + 1 removed, got %v", counts) } } func TestComputeSbomDiff_DeterministicOrder(t *testing.T) { from := []sbomPackage{ {Name: "zlib", Version: "1.2", Type: "deb"}, {Name: "curl", Version: "8.0", Type: "deb"}, {Name: "gone", Version: "0.1", Type: "npm"}, } to := []sbomPackage{ {Name: "curl", Version: "8.1", Type: "deb"}, {Name: "zlib", Version: "1.2", Type: "deb"}, {Name: "fresh", Version: "2.0", Type: "npm"}, } want := []SbomDiffEntry{ {Status: "changed", Package: sbomPackage{Name: "curl", Version: "8.1", Type: "deb"}, PrevVersion: "8.0"}, {Status: "added", Package: sbomPackage{Name: "fresh", Version: "2.0", Type: "npm"}}, {Status: "removed", Package: sbomPackage{Name: "gone", Version: "0.1", Type: "npm"}}, {Status: "unchanged", Package: sbomPackage{Name: "zlib", Version: "1.2", Type: "deb"}}, } for run := range 5 { diff := computeSbomDiff(from, to) if len(diff) != len(want) { t.Fatalf("run %d: expected %d entries, got %d", run, len(want), len(diff)) } for i := range want { if diff[i] != want[i] { t.Errorf("run %d entry %d: expected %+v, got %+v", run, i, want[i], diff[i]) } } } } func TestComputeDiffSummary_PackageCounts(t *testing.T) { sbomDiff := []SbomDiffEntry{ {Status: "added"}, {Status: "added"}, {Status: "removed"}, {Status: "changed"}, {Status: "unchanged"}, } summary := computeDiffSummary(nil, nil, nil, false, sbomDiff, true) if summary.PkgAddedCount != 2 { t.Errorf("expected 2 added, got %d", summary.PkgAddedCount) } if summary.PkgRemovedCount != 1 { t.Errorf("expected 1 removed, got %d", summary.PkgRemovedCount) } if summary.PkgChangedCount != 1 { t.Errorf("expected 1 changed, got %d", summary.PkgChangedCount) } if !summary.HasSbomData { t.Error("expected HasSbomData to be true") } }