From 9daadbbb76ec70f90f22307e39dad9cf5f0bb29e Mon Sep 17 00:00:00 2001 From: Ahmed ElMallah Date: Thu, 18 Jun 2026 15:59:06 -0700 Subject: [PATCH] test(detectors): add fixture-based tests for gomod, gradle, maven, syft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These four detectors had no committed-fixture tests for their graph builders. Add testdata fixtures + tests that drive the parsers directly, with no dependency on the go / gradle / mvn / syft tooling: - gomod: testdata/demo/{go.mod, go-list-deps.json} → parseGoModFile + depGraphFromGoList (package set, transitive edges, runtime vs test-only development scope). - maven: testdata/dependency-tree.tgf → depGraphFromMavenTGF (edges, compile→runtime / test→development scope). - gradle: testdata/dependencies.txt → depGraphFromGradleOutput (runtimeClasspath vs testRuntimeClasspath scope). - syft: graphFromSyftSBOM mapping test built from a hand-constructed Syft SBOM struct (the builtin path consumes a library struct, not a text manifest); asserts package→node, dependency-of→edge, license carry-through. The other detectors (nuget + cargo/pub/ruby/conan/cocoapods/githubactions) already drive ResolveGraph against testdata/project fixtures via their committed-lock fast-paths, so no redundant tests were added there. Co-Authored-By: Claude Opus 4.8 --- internal/detectors/gomod/fixture_test.go | 96 +++++++++++++++++++ .../gomod/testdata/demo/go-list-deps.json | 7 ++ internal/detectors/gomod/testdata/demo/go.mod | 10 ++ internal/detectors/gradle/fixture_test.go | 72 ++++++++++++++ .../gradle/testdata/dependencies.txt | 14 +++ internal/detectors/maven/fixture_test.go | 74 ++++++++++++++ .../maven/testdata/dependency-tree.tgf | 18 ++++ internal/detectors/syft/graph_mapping_test.go | 86 +++++++++++++++++ 8 files changed, 377 insertions(+) create mode 100644 internal/detectors/gomod/fixture_test.go create mode 100644 internal/detectors/gomod/testdata/demo/go-list-deps.json create mode 100644 internal/detectors/gomod/testdata/demo/go.mod create mode 100644 internal/detectors/gradle/fixture_test.go create mode 100644 internal/detectors/gradle/testdata/dependencies.txt create mode 100644 internal/detectors/maven/fixture_test.go create mode 100644 internal/detectors/maven/testdata/dependency-tree.tgf create mode 100644 internal/detectors/syft/graph_mapping_test.go diff --git a/internal/detectors/gomod/fixture_test.go b/internal/detectors/gomod/fixture_test.go new file mode 100644 index 00000000..b9af17d3 --- /dev/null +++ b/internal/detectors/gomod/fixture_test.go @@ -0,0 +1,96 @@ +package gomod + +import ( + "os" + "path/filepath" + "testing" + + "github.com/bomly-dev/bomly-cli/sdk" +) + +// readFixture loads a committed fixture file from testdata. These tests drive +// the go.mod and `go list -deps -json` parsers directly against real fixtures, +// so they never invoke the `go` toolchain. +func readFixture(t *testing.T, parts ...string) []byte { + t.Helper() + raw, err := os.ReadFile(filepath.Join(append([]string{"testdata"}, parts...)...)) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + return raw +} + +func TestGoModFixture_ParseModAndGraph(t *testing.T) { + module, requires, err := parseGoModFile(filepath.Join("testdata", "demo", "go.mod")) + if err != nil { + t.Fatalf("parseGoModFile: %v", err) + } + if module != "example.com/demo" { + t.Fatalf("module = %q, want example.com/demo", module) + } + // go.mod declares two direct requires plus one indirect. + if len(requires) != 3 { + t.Fatalf("requires = %d, want 3: %#v", len(requires), requires) + } + + raw := readFixture(t, "demo", "go-list-deps.json") + g, err := depGraphFromGoList(raw, module, requires) + if err != nil { + t.Fatalf("depGraphFromGoList: %v", err) + } + + if g.Size() != 5 { + t.Fatalf("graph size = %d, want 5: %v", g.Size(), nodeIDs(g)) + } + for _, want := range []string{ + "github.com/google/uuid@v1.6.0", + "golang.org/x/text@v0.14.0", + "github.com/stretchr/testify@v1.9.0", + "github.com/davecgh/go-spew@v1.1.1", + } { + if _, ok := g.Node(want); !ok { + t.Errorf("missing node %s; present: %v", want, nodeIDs(g)) + } + } + // stdlib must never appear as a dependency node. + if _, ok := g.Node("fmt"); ok { + t.Error("stdlib package fmt should not be a node") + } +} + +func TestGoModFixture_Scopes(t *testing.T) { + module, requires, err := parseGoModFile(filepath.Join("testdata", "demo", "go.mod")) + if err != nil { + t.Fatalf("parseGoModFile: %v", err) + } + g, err := depGraphFromGoList(readFixture(t, "demo", "go-list-deps.json"), module, requires) + if err != nil { + t.Fatalf("depGraphFromGoList: %v", err) + } + + // Imported via main → runtime; reachable only through TestImports → development. + requireScope(t, g, "github.com/google/uuid@v1.6.0", sdk.ScopeRuntime) + requireScope(t, g, "golang.org/x/text@v0.14.0", sdk.ScopeRuntime) + requireScope(t, g, "github.com/stretchr/testify@v1.9.0", sdk.ScopeDevelopment) + requireScope(t, g, "github.com/davecgh/go-spew@v1.1.1", sdk.ScopeDevelopment) +} + +func nodeIDs(g *sdk.Graph) []string { + nodes := g.Nodes() + ids := make([]string, len(nodes)) + for i, n := range nodes { + ids[i] = n.ID + } + return ids +} + +func requireScope(t *testing.T, g *sdk.Graph, id string, scope sdk.Scope) { + t.Helper() + n, ok := g.Node(id) + if !ok { + t.Fatalf("missing node %s", id) + } + if got := n.PrimaryScope(); got != scope { + t.Errorf("%s scope = %q, want %q", id, got, scope) + } +} diff --git a/internal/detectors/gomod/testdata/demo/go-list-deps.json b/internal/detectors/gomod/testdata/demo/go-list-deps.json new file mode 100644 index 00000000..04757f54 --- /dev/null +++ b/internal/detectors/gomod/testdata/demo/go-list-deps.json @@ -0,0 +1,7 @@ +{"ImportPath":"example.com/demo","Module":{"Path":"example.com/demo","Main":true},"Imports":["example.com/demo/internal/app","github.com/google/uuid"],"TestImports":["github.com/stretchr/testify/require"]} +{"ImportPath":"example.com/demo/internal/app","Module":{"Path":"example.com/demo","Main":true},"Imports":["golang.org/x/text/language","fmt"]} +{"ImportPath":"github.com/google/uuid","Module":{"Path":"github.com/google/uuid","Version":"v1.6.0"},"Imports":["crypto/rand"]} +{"ImportPath":"golang.org/x/text/language","Module":{"Path":"golang.org/x/text","Version":"v0.14.0"}} +{"ImportPath":"github.com/stretchr/testify/require","Module":{"Path":"github.com/stretchr/testify","Version":"v1.9.0"},"Imports":["github.com/stretchr/testify/assert"]} +{"ImportPath":"github.com/stretchr/testify/assert","Module":{"Path":"github.com/stretchr/testify","Version":"v1.9.0"},"Imports":["github.com/davecgh/go-spew/spew"]} +{"ImportPath":"github.com/davecgh/go-spew/spew","Module":{"Path":"github.com/davecgh/go-spew","Version":"v1.1.1"}} diff --git a/internal/detectors/gomod/testdata/demo/go.mod b/internal/detectors/gomod/testdata/demo/go.mod new file mode 100644 index 00000000..76793536 --- /dev/null +++ b/internal/detectors/gomod/testdata/demo/go.mod @@ -0,0 +1,10 @@ +module example.com/demo + +go 1.21 + +require ( + github.com/google/uuid v1.6.0 + golang.org/x/text v0.14.0 +) + +require github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/internal/detectors/gradle/fixture_test.go b/internal/detectors/gradle/fixture_test.go new file mode 100644 index 00000000..243244a3 --- /dev/null +++ b/internal/detectors/gradle/fixture_test.go @@ -0,0 +1,72 @@ +package gradle + +import ( + "os" + "path/filepath" + "testing" + + "github.com/bomly-dev/bomly-cli/sdk" +) + +// TestGradleDependenciesFixture drives the `gradle dependencies` output parser +// against a committed fixture, exercising the real text-tree shape without +// invoking Gradle. +func TestGradleDependenciesFixture(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "dependencies.txt")) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + g, err := depGraphFromGradleOutput(raw, "demo-app") + if err != nil { + t.Fatalf("depGraphFromGradleOutput: %v", err) + } + + for _, want := range []string{ + "org.springframework:spring-web@6.1.3", + "org.springframework:spring-jcl@6.1.3", + "com.google.guava:guava@33.0.0-jre", + "org.apache.commons:commons-lang3@3.14.0", + "org.junit.jupiter:junit-jupiter@5.10.2", + "org.mockito:mockito-core@5.10.0", + } { + if _, ok := g.Node(want); !ok { + t.Errorf("missing node %s", want) + } + } + + requireGradleEdge(t, g, "demo-app", "com.google.guava:guava@33.0.0-jre") + requireGradleEdge(t, g, "com.google.guava:guava@33.0.0-jre", "com.google.guava:failureaccess@1.0.2") + requireGradleEdge(t, g, "org.springframework:spring-core@6.1.3", "org.springframework:spring-jcl@6.1.3") + requireGradleEdge(t, g, "org.junit.jupiter:junit-jupiter@5.10.2", "org.junit.jupiter:junit-jupiter-api@5.10.2") + + // runtimeClasspath → runtime; testRuntimeClasspath → development. + requireGradleScope(t, g, "com.google.guava:guava@33.0.0-jre", sdk.ScopeRuntime) + requireGradleScope(t, g, "org.apache.commons:commons-lang3@3.14.0", sdk.ScopeRuntime) + requireGradleScope(t, g, "org.junit.jupiter:junit-jupiter@5.10.2", sdk.ScopeDevelopment) + requireGradleScope(t, g, "org.mockito:mockito-core@5.10.0", sdk.ScopeDevelopment) +} + +func requireGradleEdge(t *testing.T, g *sdk.Graph, fromID, toID string) { + t.Helper() + deps, err := g.DirectDependencies(fromID) + if err != nil { + t.Fatalf("dependencies(%s): %v", fromID, err) + } + for _, d := range deps { + if d.ID == toID { + return + } + } + t.Errorf("expected edge %s → %s", fromID, toID) +} + +func requireGradleScope(t *testing.T, g *sdk.Graph, id string, scope sdk.Scope) { + t.Helper() + n, ok := g.Node(id) + if !ok { + t.Fatalf("missing node %s", id) + } + if got := n.PrimaryScope(); got != scope { + t.Errorf("%s scope = %q, want %q", id, got, scope) + } +} diff --git a/internal/detectors/gradle/testdata/dependencies.txt b/internal/detectors/gradle/testdata/dependencies.txt new file mode 100644 index 00000000..a93af1f0 --- /dev/null +++ b/internal/detectors/gradle/testdata/dependencies.txt @@ -0,0 +1,14 @@ +runtimeClasspath - Runtime classpath of source set 'main'. ++--- org.springframework:spring-web:6.1.3 +| +--- org.springframework:spring-beans:6.1.3 +| \--- org.springframework:spring-core:6.1.3 +| \--- org.springframework:spring-jcl:6.1.3 ++--- com.google.guava:guava:33.0.0-jre +| +--- com.google.guava:failureaccess:1.0.2 +| \--- org.checkerframework:checker-qual:3.41.0 +\--- org.apache.commons:commons-lang3:3.14.0 + +testRuntimeClasspath - Test runtime classpath of source set 'test'. ++--- org.junit.jupiter:junit-jupiter:5.10.2 +| \--- org.junit.jupiter:junit-jupiter-api:5.10.2 +\--- org.mockito:mockito-core:5.10.0 diff --git a/internal/detectors/maven/fixture_test.go b/internal/detectors/maven/fixture_test.go new file mode 100644 index 00000000..2c45ea6e --- /dev/null +++ b/internal/detectors/maven/fixture_test.go @@ -0,0 +1,74 @@ +package maven + +import ( + "os" + "path/filepath" + "testing" + + "github.com/bomly-dev/bomly-cli/sdk" +) + +// TestMavenTGFFixture drives the dependency-tree (TGF) parser against a committed +// fixture captured from `mvn dependency:tree -DoutputType=tgf`, so it exercises +// the real output shape without invoking Maven. +func TestMavenTGFFixture(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "dependency-tree.tgf")) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + g, err := depGraphFromMavenTGF(raw) + if err != nil { + t.Fatalf("depGraphFromMavenTGF: %v", err) + } + + const root = "com.bomly:example-app@1.0.0" + for _, want := range []string{ + root, + "org.springframework:spring-web@5.3.30", + "commons-fileupload:commons-fileupload@1.4", + "commons-io:commons-io@2.11.0", + "org.mindrot:jbcrypt@0.4", + "junit:junit@4.13.2", + "org.hamcrest:hamcrest-core@1.3", + } { + if _, ok := g.Node(want); !ok { + t.Errorf("missing node %s", want) + } + } + + requireMavenEdge(t, g, root, "org.springframework:spring-web@5.3.30") + requireMavenEdge(t, g, "org.springframework:spring-web@5.3.30", "org.springframework:spring-core@5.3.30") + requireMavenEdge(t, g, "commons-fileupload:commons-fileupload@1.4", "commons-io:commons-io@2.11.0") + requireMavenEdge(t, g, "junit:junit@4.13.2", "org.hamcrest:hamcrest-core@1.3") + + // compile → runtime, test → development. + requireMavenScope(t, g, "org.springframework:spring-web@5.3.30", sdk.ScopeRuntime) + requireMavenScope(t, g, "commons-io:commons-io@2.11.0", sdk.ScopeRuntime) + requireMavenScope(t, g, "junit:junit@4.13.2", sdk.ScopeDevelopment) + requireMavenScope(t, g, "org.hamcrest:hamcrest-core@1.3", sdk.ScopeDevelopment) +} + +func requireMavenEdge(t *testing.T, g *sdk.Graph, fromID, toID string) { + t.Helper() + deps, err := g.DirectDependencies(fromID) + if err != nil { + t.Fatalf("dependencies(%s): %v", fromID, err) + } + for _, d := range deps { + if d.ID == toID { + return + } + } + t.Errorf("expected edge %s → %s", fromID, toID) +} + +func requireMavenScope(t *testing.T, g *sdk.Graph, id string, scope sdk.Scope) { + t.Helper() + n, ok := g.Node(id) + if !ok { + t.Fatalf("missing node %s", id) + } + if got := n.PrimaryScope(); got != scope { + t.Errorf("%s scope = %q, want %q", id, got, scope) + } +} diff --git a/internal/detectors/maven/testdata/dependency-tree.tgf b/internal/detectors/maven/testdata/dependency-tree.tgf new file mode 100644 index 00000000..4844b2d8 --- /dev/null +++ b/internal/detectors/maven/testdata/dependency-tree.tgf @@ -0,0 +1,18 @@ +1 com.bomly:example-app:jar:1.0.0 +2 org.springframework:spring-web:jar:5.3.30:compile +3 org.springframework:spring-core:jar:5.3.30:compile +4 org.springframework:spring-beans:jar:5.3.30:compile +5 commons-fileupload:commons-fileupload:jar:1.4:compile +6 commons-io:commons-io:jar:2.11.0:compile +7 org.mindrot:jbcrypt:jar:0.4:compile +8 junit:junit:jar:4.13.2:test +9 org.hamcrest:hamcrest-core:jar:1.3:test +# +1 2 compile +1 5 compile +1 7 compile +1 8 test +2 3 compile +2 4 compile +5 6 compile +8 9 test diff --git a/internal/detectors/syft/graph_mapping_test.go b/internal/detectors/syft/graph_mapping_test.go new file mode 100644 index 00000000..094c1741 --- /dev/null +++ b/internal/detectors/syft/graph_mapping_test.go @@ -0,0 +1,86 @@ +//go:build !bomly_external_syft + +package syft + +import ( + "testing" + + "github.com/anchore/syft/syft/artifact" + syftpkg "github.com/anchore/syft/syft/pkg" + syftsbom "github.com/anchore/syft/syft/sbom" + "github.com/bomly-dev/bomly-cli/sdk" +) + +// TestGraphFromSyftSBOMMapsPackagesEdgesLicenses exercises the builtin Syft → +// sdk.Graph mapping with a hand-built SBOM. The Syft graph builder consumes the +// Syft library's SBOM struct (not a text manifest), so the fixture is the struct +// itself; the test invokes no Syft binary. +func TestGraphFromSyftSBOMMapsPackagesEdgesLicenses(t *testing.T) { + requests := syftpkg.Package{ + Name: "requests", + Version: "2.32.3", + Type: syftpkg.PythonPkg, + PURL: "pkg:pypi/requests@2.32.3", + } + requests.SetID() + + certifi := syftpkg.Package{ + Name: "certifi", + Version: "2024.8.30", + Type: syftpkg.PythonPkg, + PURL: "pkg:pypi/certifi@2024.8.30", + Licenses: syftpkg.NewLicenseSet(syftpkg.NewLicense("MPL-2.0")), + } + certifi.SetID() + + sb := &syftsbom.SBOM{ + Artifacts: syftsbom.Artifacts{ + Packages: syftpkg.NewCollection(requests, certifi), + }, + Relationships: []artifact.Relationship{ + // certifi is a dependency-of requests → edge requests → certifi. + {From: certifi, To: requests, Type: artifact.DependencyOfRelationship}, + }, + } + + g, err := graphFromSyftSBOM(sb) + if err != nil { + t.Fatalf("graphFromSyftSBOM: %v", err) + } + if g.Size() != 2 { + t.Fatalf("graph size = %d, want 2", g.Size()) + } + + requestsNode := nodeByName(t, g, "requests") + certifiNode := nodeByName(t, g, "certifi") + + if requestsNode.Version != "2.32.3" || requestsNode.PURL != "pkg:pypi/requests@2.32.3" { + t.Errorf("unexpected requests coordinates: %+v", requestsNode.Coordinates) + } + + // Dependency-of relationship becomes a parent → child edge. + deps, err := g.DirectDependencies(requestsNode.ID) + if err != nil { + t.Fatalf("dependencies(requests): %v", err) + } + if len(deps) != 1 || deps[0].ID != certifiNode.ID { + t.Errorf("expected requests → certifi edge, got %+v", deps) + } + + // License carried through from the Syft package. + licenses := sdk.DetectionLicenses(certifiNode) + if len(licenses) == 0 || licenses[0].Value != "MPL-2.0" { + t.Errorf("expected MPL-2.0 license on certifi, got %+v", licenses) + } +} + +func nodeByName(t *testing.T, g *sdk.Graph, name string) *sdk.Dependency { + t.Helper() + for _, n := range g.Nodes() { + if n.Name == name { + return n + } + } + t.Fatalf("no node named %q", name) + return nil +}