internal/{frontend,vuln}: move logic to search by module to vuln client

Moves the logic for finding entries based on a module/package prefix
to the vuln client, so it is easier to test the core functionality.

Also modifies the functionality slightly, so that a search query
with a trailing "/" will show results.

Change-Id: Ief191a62bcf7badbc6e70112d9fcedae3dd03275
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/486456
Run-TryBot: Tatiana Bradley <tatianabradley@google.com>
Reviewed-by: Julie Qiu <julieqiu@google.com>
TryBot-Result: kokoro <noreply+kokoro@google.com>
Reviewed-by: Tatiana Bradley <tatianabradley@google.com>
diff --git a/internal/frontend/search.go b/internal/frontend/search.go
index 766d5a2..ffd6c30 100644
--- a/internal/frontend/search.go
+++ b/internal/frontend/search.go
@@ -23,7 +23,6 @@
 	"golang.org/x/pkgsite/internal/derrors"
 	"golang.org/x/pkgsite/internal/log"
 	"golang.org/x/pkgsite/internal/middleware"
-	"golang.org/x/pkgsite/internal/osv"
 	"golang.org/x/pkgsite/internal/postgres"
 	"golang.org/x/pkgsite/internal/stdlib"
 	"golang.org/x/pkgsite/internal/version"
@@ -373,36 +372,12 @@
 	if mode != searchModeVuln || client == nil {
 		return nil, nil
 	}
-	allEntries, err := client.Entries(ctx, -1)
+
+	entries, err := client.ByPackagePrefix(ctx, query)
 	if err != nil {
 		return nil, err
 	}
 
-	prefix := query + "/"
-	// Returns whether any of the affected modules or packages of the
-	// entry start with the search query.
-	matchesQuery := func(e *osv.Entry) bool {
-		for _, aff := range e.Affected {
-			if aff.Module.Path == query ||
-				strings.HasPrefix(aff.Module.Path, prefix) {
-				return true
-			}
-			for _, pkg := range aff.EcosystemSpecific.Packages {
-				if pkg.Path == query || strings.HasPrefix(pkg.Path, prefix) {
-					return true
-				}
-			}
-		}
-		return false
-	}
-
-	var entries []*osv.Entry
-	for _, entry := range allEntries {
-		if matchesQuery(entry) {
-			entries = append(entries, entry)
-		}
-	}
-
 	return &searchAction{
 		title:    fmt.Sprintf("%s - Vulnerability Reports", query),
 		template: "vuln/list",
diff --git a/internal/vuln/client.go b/internal/vuln/client.go
index 1b43079..d5f60f6 100644
--- a/internal/vuln/client.go
+++ b/internal/vuln/client.go
@@ -11,6 +11,7 @@
 	"fmt"
 	"path/filepath"
 	"sort"
+	"strings"
 	"sync"
 
 	"golang.org/x/pkgsite/internal/derrors"
@@ -238,6 +239,52 @@
 	return c.byIDs(ctx, ids)
 }
 
+// ByPackagePrefix returns all the OSV entries that match the given
+// package prefix, in descending order by ID, or (nil, nil) if there
+// are none.
+//
+// An entry matches a prefix if:
+//   - Any affected module or package equals the given prefix, OR
+//   - Any affected module or package's path begins with the given prefix
+//     interpreted as a full path. (E.g. "example.com/module/package" matches
+//     the prefix "example.com/module" but not "example.com/mod")
+func (c *Client) ByPackagePrefix(ctx context.Context, prefix string) (_ []*osv.Entry, err error) {
+	allEntries, err := c.Entries(ctx, -1)
+	if err != nil {
+		return nil, err
+	}
+
+	prefix = strings.TrimSuffix(prefix, "/")
+	match := func(s string) bool {
+		return s == prefix || strings.HasPrefix(s, prefix+"/")
+	}
+
+	// Returns whether any of the affected modules or packages of the
+	// entry start with the prefix.
+	matchesQuery := func(e *osv.Entry) bool {
+		for _, aff := range e.Affected {
+			if match(aff.Module.Path) {
+				return true
+			}
+			for _, pkg := range aff.EcosystemSpecific.Packages {
+				if match(pkg.Path) {
+					return true
+				}
+			}
+		}
+		return false
+	}
+
+	var entries []*osv.Entry
+	for _, entry := range allEntries {
+		if matchesQuery(entry) {
+			entries = append(entries, entry)
+		}
+	}
+
+	return entries, nil
+}
+
 func (c *Client) byIDs(ctx context.Context, ids []string) (_ []*osv.Entry, err error) {
 	entries := make([]*osv.Entry, len(ids))
 	g, gctx := errgroup.WithContext(ctx)
diff --git a/internal/vuln/client_test.go b/internal/vuln/client_test.go
index be630b6..e48cb14 100644
--- a/internal/vuln/client_test.go
+++ b/internal/vuln/client_test.go
@@ -14,6 +14,7 @@
 	"testing"
 	"time"
 
+	"github.com/google/go-cmp/cmp"
 	"golang.org/x/pkgsite/internal/osv"
 	"golang.org/x/tools/txtar"
 )
@@ -386,6 +387,150 @@
 	}
 }
 
+func TestByPackagePrefix(t *testing.T) {
+	stdlibNet := &osv.Entry{
+		ID: "1-STDLIB-NET",
+		Affected: []osv.Affected{
+			{
+				Module: osv.Module{
+					Path: "golang.org/x/example",
+				},
+			},
+			{
+				Module: osv.Module{
+					Path: "stdlib",
+				},
+				EcosystemSpecific: osv.EcosystemSpecific{
+					Packages: []osv.Package{
+						{
+							Path: "net/http/httputil",
+						},
+					},
+				},
+			},
+		},
+	}
+	stdlibCrypto := &osv.Entry{
+		ID: "2-STDLIB-CRYPTO",
+		Affected: []osv.Affected{
+			{
+				Module: osv.Module{
+					Path: "golang.org/x/example",
+				},
+			},
+			{
+				Module: osv.Module{
+					Path: "stdlib",
+				},
+				EcosystemSpecific: osv.EcosystemSpecific{
+					Packages: []osv.Package{
+						{
+							Path: "crypto/tls",
+						},
+					},
+				},
+			},
+		},
+	}
+	thirdParty := &osv.Entry{
+		ID: "3-EXAMPLE-COM",
+		Affected: []osv.Affected{
+			{
+				Module: osv.Module{
+					Path: "golang.org/x/example",
+				},
+			},
+			{
+
+				Module: osv.Module{
+					Path: "example.com/org/module",
+				},
+				EcosystemSpecific: osv.EcosystemSpecific{
+					Packages: []osv.Package{
+						{
+							Path: "example.com/org/module/somepkg",
+						},
+						{
+							Path: "example.com/org/module/package/inner",
+						},
+					},
+				},
+			},
+		},
+	}
+	vc, err := NewInMemoryClient([]*osv.Entry{stdlibCrypto, stdlibNet, thirdParty})
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, tc := range []struct {
+		name  string
+		query string
+		want  []*osv.Entry
+	}{
+		{
+			name:  "no match",
+			query: "net/htt",
+			want:  nil,
+		},
+		{
+			name:  "stdlib module exact match",
+			query: "stdlib",
+			want:  []*osv.Entry{stdlibCrypto, stdlibNet},
+		},
+		{
+			name:  "stdlib package exact match",
+			query: "net/http/httputil",
+			want:  []*osv.Entry{stdlibNet},
+		},
+		{
+			name:  "stdlib package prefix match",
+			query: "net/http",
+			want:  []*osv.Entry{stdlibNet},
+		},
+		{
+			name:  "3p module exact match",
+			query: "example.com/org/module",
+			want:  []*osv.Entry{thirdParty},
+		},
+		{
+			name:  "3p module prefix match",
+			query: "example.com/org",
+			want:  []*osv.Entry{thirdParty},
+		},
+		{
+			name:  "3p package exact match",
+			query: "example.com/org/module/package/inner",
+			want:  []*osv.Entry{thirdParty},
+		},
+		{
+			name:  "3p package prefix match",
+			query: "example.com/org/module/package",
+			want:  []*osv.Entry{thirdParty},
+		},
+		{
+			name:  "prefix with trailing slash",
+			query: "example.com/org/module/package/",
+			want:  []*osv.Entry{thirdParty},
+		},
+		{
+			name:  "descending order by ID",
+			query: "golang.org/x",
+			want:  []*osv.Entry{thirdParty, stdlibCrypto, stdlibNet},
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			got, err := vc.ByPackagePrefix(context.Background(), tc.query)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			if !cmp.Equal(got, tc.want) {
+				t.Errorf("ByPackagePrefix(%s) = %v, want %v", tc.query, got, tc.want)
+			}
+		})
+	}
+}
+
 // newTestClientFromTxtar creates an in-memory client for use in tests.
 // It reads test data from a txtar file which must follow the
 // v1 database schema.