github.com/nextlinux/gosbom@v0.81.1-0.20230627115839-1ff50c281391/gosbom/pkg/cataloger/internal/pkgtest/test_generic_parser.go (about)

     1  package pkgtest
     2  
     3  import (
     4  	"fmt"
     5  	"io"
     6  	"os"
     7  	"strings"
     8  	"testing"
     9  
    10  	"github.com/google/go-cmp/cmp"
    11  	"github.com/google/go-cmp/cmp/cmpopts"
    12  	"github.com/nextlinux/gosbom/gosbom/artifact"
    13  	"github.com/nextlinux/gosbom/gosbom/file"
    14  	"github.com/nextlinux/gosbom/gosbom/linux"
    15  	"github.com/nextlinux/gosbom/gosbom/pkg"
    16  	"github.com/nextlinux/gosbom/gosbom/pkg/cataloger/generic"
    17  	"github.com/nextlinux/gosbom/gosbom/source"
    18  	"github.com/stretchr/testify/assert"
    19  	"github.com/stretchr/testify/require"
    20  
    21  	"github.com/anchore/stereoscope/pkg/imagetest"
    22  )
    23  
    24  type locationComparer func(x, y file.Location) bool
    25  type licenseComparer func(x, y pkg.License) bool
    26  
    27  type CatalogTester struct {
    28  	expectedPkgs                   []pkg.Package
    29  	expectedRelationships          []artifact.Relationship
    30  	assertResultExpectations       bool
    31  	expectedPathResponses          []string // this is a minimum set, the resolver may return more that just this list
    32  	expectedContentQueries         []string // this is a full set, any other queries are unexpected (and will fail the test)
    33  	ignoreUnfulfilledPathResponses map[string][]string
    34  	ignoreAnyUnfulfilledPaths      []string
    35  	env                            *generic.Environment
    36  	reader                         file.LocationReadCloser
    37  	resolver                       file.Resolver
    38  	wantErr                        require.ErrorAssertionFunc
    39  	compareOptions                 []cmp.Option
    40  	locationComparer               locationComparer
    41  	licenseComparer                licenseComparer
    42  }
    43  
    44  func NewCatalogTester() *CatalogTester {
    45  	return &CatalogTester{
    46  		wantErr:          require.NoError,
    47  		locationComparer: DefaultLocationComparer,
    48  		licenseComparer:  DefaultLicenseComparer,
    49  		ignoreUnfulfilledPathResponses: map[string][]string{
    50  			"FilesByPath": {
    51  				// most catalogers search for a linux release, which will not be fulfilled in testing
    52  				"/etc/os-release",
    53  				"/usr/lib/os-release",
    54  				"/etc/system-release-cpe",
    55  				"/etc/redhat-release",
    56  				"/bin/busybox",
    57  			},
    58  		},
    59  	}
    60  }
    61  
    62  func DefaultLocationComparer(x, y file.Location) bool {
    63  	return cmp.Equal(x.Coordinates, y.Coordinates) && cmp.Equal(x.VirtualPath, y.VirtualPath)
    64  }
    65  
    66  func DefaultLicenseComparer(x, y pkg.License) bool {
    67  	return cmp.Equal(x, y, cmp.Comparer(DefaultLocationComparer), cmp.Comparer(
    68  		func(x, y file.LocationSet) bool {
    69  			xs := x.ToSlice()
    70  			ys := y.ToSlice()
    71  			if len(xs) != len(ys) {
    72  				return false
    73  			}
    74  			for i, xe := range xs {
    75  				ye := ys[i]
    76  				if !DefaultLocationComparer(xe, ye) {
    77  					return false
    78  				}
    79  			}
    80  			return true
    81  		},
    82  	))
    83  }
    84  
    85  func (p *CatalogTester) FromDirectory(t *testing.T, path string) *CatalogTester {
    86  	t.Helper()
    87  
    88  	s, err := source.NewFromDirectory(path)
    89  	require.NoError(t, err)
    90  
    91  	resolver, err := s.FileResolver(source.AllLayersScope)
    92  	require.NoError(t, err)
    93  
    94  	p.resolver = resolver
    95  	return p
    96  }
    97  
    98  func (p *CatalogTester) FromFile(t *testing.T, path string) *CatalogTester {
    99  	t.Helper()
   100  
   101  	fixture, err := os.Open(path)
   102  	require.NoError(t, err)
   103  
   104  	p.reader = file.LocationReadCloser{
   105  		Location:   file.NewLocation(fixture.Name()),
   106  		ReadCloser: fixture,
   107  	}
   108  	return p
   109  }
   110  
   111  func (p *CatalogTester) FromString(location, data string) *CatalogTester {
   112  	p.reader = file.LocationReadCloser{
   113  		Location:   file.NewLocation(location),
   114  		ReadCloser: io.NopCloser(strings.NewReader(data)),
   115  	}
   116  	return p
   117  }
   118  
   119  func (p *CatalogTester) WithLinuxRelease(r linux.Release) *CatalogTester {
   120  	if p.env == nil {
   121  		p.env = &generic.Environment{}
   122  	}
   123  	p.env.LinuxRelease = &r
   124  	return p
   125  }
   126  
   127  func (p *CatalogTester) WithEnv(env *generic.Environment) *CatalogTester {
   128  	p.env = env
   129  	return p
   130  }
   131  
   132  func (p *CatalogTester) WithError() *CatalogTester {
   133  	p.assertResultExpectations = true
   134  	p.wantErr = require.Error
   135  	return p
   136  }
   137  
   138  func (p *CatalogTester) WithErrorAssertion(a require.ErrorAssertionFunc) *CatalogTester {
   139  	p.wantErr = a
   140  	return p
   141  }
   142  
   143  func (p *CatalogTester) WithResolver(r file.Resolver) *CatalogTester {
   144  	p.resolver = r
   145  	return p
   146  }
   147  
   148  func (p *CatalogTester) WithImageResolver(t *testing.T, fixtureName string) *CatalogTester {
   149  	t.Helper()
   150  	img := imagetest.GetFixtureImage(t, "docker-archive", fixtureName)
   151  
   152  	s, err := source.NewFromImage(img, fixtureName)
   153  	require.NoError(t, err)
   154  
   155  	r, err := s.FileResolver(source.SquashedScope)
   156  	require.NoError(t, err)
   157  	p.resolver = r
   158  	return p
   159  }
   160  
   161  func (p *CatalogTester) IgnoreLocationLayer() *CatalogTester {
   162  	p.locationComparer = func(x, y file.Location) bool {
   163  		return cmp.Equal(x.Coordinates.RealPath, y.Coordinates.RealPath) && cmp.Equal(x.VirtualPath, y.VirtualPath)
   164  	}
   165  
   166  	// we need to update the license comparer to use the ignored location layer
   167  	p.licenseComparer = func(x, y pkg.License) bool {
   168  		return cmp.Equal(x, y, cmp.Comparer(p.locationComparer), cmp.Comparer(
   169  			func(x, y file.LocationSet) bool {
   170  				xs := x.ToSlice()
   171  				ys := y.ToSlice()
   172  				if len(xs) != len(ys) {
   173  					return false
   174  				}
   175  				for i, xe := range xs {
   176  					ye := ys[i]
   177  					if !p.locationComparer(xe, ye) {
   178  						return false
   179  					}
   180  				}
   181  
   182  				return true
   183  			}))
   184  	}
   185  	return p
   186  }
   187  
   188  func (p *CatalogTester) IgnorePackageFields(fields ...string) *CatalogTester {
   189  	p.compareOptions = append(p.compareOptions, cmpopts.IgnoreFields(pkg.Package{}, fields...))
   190  	return p
   191  }
   192  
   193  func (p *CatalogTester) WithCompareOptions(opts ...cmp.Option) *CatalogTester {
   194  	p.compareOptions = append(p.compareOptions, opts...)
   195  	return p
   196  }
   197  
   198  func (p *CatalogTester) Expects(pkgs []pkg.Package, relationships []artifact.Relationship) *CatalogTester {
   199  	p.assertResultExpectations = true
   200  	p.expectedPkgs = pkgs
   201  	p.expectedRelationships = relationships
   202  	return p
   203  }
   204  
   205  func (p *CatalogTester) ExpectsResolverPathResponses(locations []string) *CatalogTester {
   206  	p.expectedPathResponses = locations
   207  	return p
   208  }
   209  
   210  func (p *CatalogTester) ExpectsResolverContentQueries(locations []string) *CatalogTester {
   211  	p.expectedContentQueries = locations
   212  	return p
   213  }
   214  
   215  func (p *CatalogTester) IgnoreUnfulfilledPathResponses(paths ...string) *CatalogTester {
   216  	p.ignoreAnyUnfulfilledPaths = append(p.ignoreAnyUnfulfilledPaths, paths...)
   217  	return p
   218  }
   219  
   220  func (p *CatalogTester) TestParser(t *testing.T, parser generic.Parser) {
   221  	t.Helper()
   222  	pkgs, relationships, err := parser(p.resolver, p.env, p.reader)
   223  	p.wantErr(t, err)
   224  	p.assertPkgs(t, pkgs, relationships)
   225  }
   226  
   227  func (p *CatalogTester) TestCataloger(t *testing.T, cataloger pkg.Cataloger) {
   228  	t.Helper()
   229  
   230  	resolver := NewObservingResolver(p.resolver)
   231  
   232  	pkgs, relationships, err := cataloger.Catalog(resolver)
   233  
   234  	// this is a minimum set, the resolver may return more that just this list
   235  	for _, path := range p.expectedPathResponses {
   236  		assert.Truef(t, resolver.ObservedPathResponses(path), "expected path query for %q was not observed", path)
   237  	}
   238  
   239  	// this is a full set, any other queries are unexpected (and will fail the test)
   240  	if len(p.expectedContentQueries) > 0 {
   241  		assert.ElementsMatchf(t, p.expectedContentQueries, resolver.AllContentQueries(), "unexpected content queries observed: diff %s", cmp.Diff(p.expectedContentQueries, resolver.AllContentQueries()))
   242  	}
   243  
   244  	if p.assertResultExpectations {
   245  		p.wantErr(t, err)
   246  		p.assertPkgs(t, pkgs, relationships)
   247  	} else {
   248  		resolver.PruneUnfulfilledPathResponses(p.ignoreUnfulfilledPathResponses, p.ignoreAnyUnfulfilledPaths...)
   249  
   250  		// if we aren't testing the results, we should focus on what was searched for (for glob-centric tests)
   251  		assert.Falsef(t, resolver.HasUnfulfilledPathRequests(), "unfulfilled path requests: \n%v", resolver.PrettyUnfulfilledPathRequests())
   252  	}
   253  }
   254  
   255  // nolint:funlen
   256  func (p *CatalogTester) assertPkgs(t *testing.T, pkgs []pkg.Package, relationships []artifact.Relationship) {
   257  	t.Helper()
   258  
   259  	p.compareOptions = append(p.compareOptions,
   260  		cmpopts.IgnoreFields(pkg.Package{}, "id"), // note: ID is not deterministic for test purposes
   261  		cmpopts.SortSlices(pkg.Less),
   262  		cmp.Comparer(
   263  			func(x, y file.LocationSet) bool {
   264  				xs := x.ToSlice()
   265  				ys := y.ToSlice()
   266  
   267  				if len(xs) != len(ys) {
   268  					return false
   269  				}
   270  				for i, xe := range xs {
   271  					ye := ys[i]
   272  					if !p.locationComparer(xe, ye) {
   273  						return false
   274  					}
   275  				}
   276  
   277  				return true
   278  			},
   279  		),
   280  		cmp.Comparer(
   281  			func(x, y pkg.LicenseSet) bool {
   282  				xs := x.ToSlice()
   283  				ys := y.ToSlice()
   284  
   285  				if len(xs) != len(ys) {
   286  					return false
   287  				}
   288  				for i, xe := range xs {
   289  					ye := ys[i]
   290  					if !p.licenseComparer(xe, ye) {
   291  						return false
   292  					}
   293  				}
   294  
   295  				return true
   296  			},
   297  		),
   298  		cmp.Comparer(
   299  			p.locationComparer,
   300  		),
   301  		cmp.Comparer(
   302  			p.licenseComparer,
   303  		),
   304  	)
   305  
   306  	{
   307  		var r diffReporter
   308  		var opts []cmp.Option
   309  
   310  		opts = append(opts, p.compareOptions...)
   311  		opts = append(opts, cmp.Reporter(&r))
   312  
   313  		if diff := cmp.Diff(p.expectedPkgs, pkgs, opts...); diff != "" {
   314  			t.Log("Specific Differences:\n" + r.String())
   315  			t.Errorf("unexpected packages from parsing (-expected +actual)\n%s", diff)
   316  		}
   317  	}
   318  	{
   319  		var r diffReporter
   320  		var opts []cmp.Option
   321  
   322  		opts = append(opts, p.compareOptions...)
   323  		opts = append(opts, cmp.Reporter(&r))
   324  
   325  		if diff := cmp.Diff(p.expectedRelationships, relationships, opts...); diff != "" {
   326  			t.Log("Specific Differences:\n" + r.String())
   327  
   328  			t.Errorf("unexpected relationships from parsing (-expected +actual)\n%s", diff)
   329  		}
   330  	}
   331  }
   332  
   333  func TestFileParser(t *testing.T, fixturePath string, parser generic.Parser, expectedPkgs []pkg.Package, expectedRelationships []artifact.Relationship) {
   334  	t.Helper()
   335  	NewCatalogTester().FromFile(t, fixturePath).Expects(expectedPkgs, expectedRelationships).TestParser(t, parser)
   336  }
   337  
   338  func TestFileParserWithEnv(t *testing.T, fixturePath string, parser generic.Parser, env *generic.Environment, expectedPkgs []pkg.Package, expectedRelationships []artifact.Relationship) {
   339  	t.Helper()
   340  
   341  	NewCatalogTester().FromFile(t, fixturePath).WithEnv(env).Expects(expectedPkgs, expectedRelationships).TestParser(t, parser)
   342  }
   343  
   344  func AssertPackagesEqual(t *testing.T, a, b pkg.Package) {
   345  	t.Helper()
   346  	opts := []cmp.Option{
   347  		cmpopts.IgnoreFields(pkg.Package{}, "id"), // note: ID is not deterministic for test purposes
   348  		cmp.Comparer(
   349  			func(x, y file.LocationSet) bool {
   350  				xs := x.ToSlice()
   351  				ys := y.ToSlice()
   352  
   353  				if len(xs) != len(ys) {
   354  					return false
   355  				}
   356  				for i, xe := range xs {
   357  					ye := ys[i]
   358  					if !DefaultLocationComparer(xe, ye) {
   359  						return false
   360  					}
   361  				}
   362  
   363  				return true
   364  			},
   365  		),
   366  		cmp.Comparer(
   367  			func(x, y pkg.LicenseSet) bool {
   368  				xs := x.ToSlice()
   369  				ys := y.ToSlice()
   370  
   371  				if len(xs) != len(ys) {
   372  					return false
   373  				}
   374  				for i, xe := range xs {
   375  					ye := ys[i]
   376  					if !DefaultLicenseComparer(xe, ye) {
   377  						return false
   378  					}
   379  				}
   380  
   381  				return true
   382  			},
   383  		),
   384  		cmp.Comparer(
   385  			DefaultLocationComparer,
   386  		),
   387  		cmp.Comparer(
   388  			DefaultLicenseComparer,
   389  		),
   390  	}
   391  
   392  	if diff := cmp.Diff(a, b, opts...); diff != "" {
   393  		t.Errorf("unexpected packages from parsing (-expected +actual)\n%s", diff)
   394  	}
   395  }
   396  
   397  // diffReporter is a simple custom reporter that only records differences detected during comparison.
   398  type diffReporter struct {
   399  	path  cmp.Path
   400  	diffs []string
   401  }
   402  
   403  func (r *diffReporter) PushStep(ps cmp.PathStep) {
   404  	r.path = append(r.path, ps)
   405  }
   406  
   407  func (r *diffReporter) Report(rs cmp.Result) {
   408  	if !rs.Equal() {
   409  		vx, vy := r.path.Last().Values()
   410  		r.diffs = append(r.diffs, fmt.Sprintf("%#v:\n\t-: %+v\n\t+: %+v\n", r.path, vx, vy))
   411  	}
   412  }
   413  
   414  func (r *diffReporter) PopStep() {
   415  	r.path = r.path[:len(r.path)-1]
   416  }
   417  
   418  func (r *diffReporter) String() string {
   419  	return strings.Join(r.diffs, "\n")
   420  }