Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions internal/detectors/gomod/fixture_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
7 changes: 7 additions & 0 deletions internal/detectors/gomod/testdata/demo/go-list-deps.json
Original file line number Diff line number Diff line change
@@ -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"}}
10 changes: 10 additions & 0 deletions internal/detectors/gomod/testdata/demo/go.mod
Original file line number Diff line number Diff line change
@@ -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
72 changes: 72 additions & 0 deletions internal/detectors/gradle/fixture_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
14 changes: 14 additions & 0 deletions internal/detectors/gradle/testdata/dependencies.txt
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions internal/detectors/maven/fixture_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
18 changes: 18 additions & 0 deletions internal/detectors/maven/testdata/dependency-tree.tgf
Original file line number Diff line number Diff line change
@@ -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
86 changes: 86 additions & 0 deletions internal/detectors/syft/graph_mapping_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading