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) 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, ) 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, ) 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") } }