pkg-proxy/internal/server/server_test.go
Andrew Nesbitt d62c42b8d7
Add mirror command and API for selective package mirroring
Add a `proxy mirror` CLI command and `/api/mirror` API endpoints that
pre-populate the cache from various input sources: individual PURLs,
SBOM files (CycloneDX and SPDX), or full registry enumeration.

The mirror reuses the existing handler.Proxy.GetOrFetchArtifact()
pipeline so cached artifacts are identical to those fetched on demand.
A bounded worker pool controls download parallelism.

Metadata caching is opt-in via `cache_metadata: true` in config (or
PROXY_CACHE_METADATA=true). The mirror command always enables it. When
enabled, upstream metadata responses are stored for offline fallback
with ETag-based conditional revalidation.

New internal/mirror package with Source interface, PURLSource,
SBOMSource, RegistrySource, and async JobStore. New metadata_cache
database table for offline metadata serving.
2026-04-13 09:01:04 +01:00

1025 lines
28 KiB
Go

package server
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/git-pkgs/proxy/internal/config"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/handler"
"github.com/git-pkgs/proxy/internal/storage"
"github.com/git-pkgs/registries/fetch"
"github.com/go-chi/chi/v5"
)
type testServer struct {
handler http.Handler
db *database.DB
storage storage.Storage
tempDir string
}
func newTestServer(t *testing.T) *testServer {
t.Helper()
tempDir, err := os.MkdirTemp("", "proxy-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
dbPath := filepath.Join(tempDir, "test.db")
storagePath := filepath.Join(tempDir, "artifacts")
db, err := database.Create(dbPath)
if err != nil {
_ = os.RemoveAll(tempDir)
t.Fatalf("failed to create database: %v", err)
}
store, err := storage.NewFilesystem(storagePath)
if err != nil {
_ = db.Close()
_ = os.RemoveAll(tempDir)
t.Fatalf("failed to create storage: %v", err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
fetcher := fetch.NewFetcher()
resolver := fetch.NewResolver()
proxy := handler.NewProxy(db, store, fetcher, resolver, logger)
cfg := &config.Config{
BaseURL: "http://localhost:8080",
Storage: config.StorageConfig{Path: storagePath},
Database: config.DatabaseConfig{Path: dbPath},
}
r := chi.NewRouter()
// Mount handlers
npmHandler := handler.NewNPMHandler(proxy, cfg.BaseURL)
cargoHandler := handler.NewCargoHandler(proxy, cfg.BaseURL)
gemHandler := handler.NewGemHandler(proxy, cfg.BaseURL)
goHandler := handler.NewGoHandler(proxy, cfg.BaseURL)
pypiHandler := handler.NewPyPIHandler(proxy, cfg.BaseURL)
r.Mount("/npm", http.StripPrefix("/npm", npmHandler.Routes()))
r.Mount("/cargo", http.StripPrefix("/cargo", cargoHandler.Routes()))
r.Mount("/gem", http.StripPrefix("/gem", gemHandler.Routes()))
r.Mount("/go", http.StripPrefix("/go", goHandler.Routes()))
r.Mount("/pypi", http.StripPrefix("/pypi", pypiHandler.Routes()))
// Create a minimal server struct for the handlers
s := &Server{
cfg: cfg,
db: db,
storage: store,
logger: logger,
templates: &Templates{},
}
r.Get("/health", s.handleHealth)
r.Get("/stats", s.handleStats)
r.Get("/openapi.json", s.handleOpenAPIJSON)
r.Mount("/static", http.StripPrefix("/static/", staticHandler()))
r.Get("/search", s.handleSearch)
r.Get("/package/{ecosystem}/*", s.handlePackagePath)
r.Get("/api/browse/{ecosystem}/*", s.handleBrowsePath)
r.Get("/api/compare/{ecosystem}/*", s.handleComparePath)
r.Get("/", s.handleRoot)
r.Get("/install", s.handleInstall)
r.Get("/packages", s.handlePackagesList)
return &testServer{
handler: r,
db: db,
storage: store,
tempDir: tempDir,
}
}
func (ts *testServer) close() {
_ = ts.db.Close()
_ = os.RemoveAll(ts.tempDir)
}
// seedTestPackage creates a package, version, and artifact in the database for testing
// page rendering. The package is created under the npm ecosystem with version 1.0.0.
func seedTestPackage(t *testing.T, db *database.DB, name string) {
t.Helper()
pkg := &database.Package{
PURL: "pkg:npm/" + name,
Ecosystem: "npm",
Name: name,
}
if err := db.UpsertPackage(pkg); err != nil {
t.Fatalf("failed to upsert package: %v", err)
}
ver := &database.Version{
PURL: "pkg:npm/" + name + "@1.0.0",
PackagePURL: pkg.PURL,
}
if err := db.UpsertVersion(ver); err != nil {
t.Fatalf("failed to upsert version: %v", err)
}
artifact := &database.Artifact{
VersionPURL: ver.PURL,
Filename: name + "-1.0.0.tgz",
UpstreamURL: "https://registry.npmjs.org/" + name + "/-/" + name + "-1.0.0.tgz",
StoragePath: sql.NullString{String: "/tmp/test.tgz", Valid: true},
}
if err := db.UpsertArtifact(artifact); err != nil {
t.Fatalf("failed to upsert artifact: %v", err)
}
}
func TestHandleOpenAPIJSON(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
req := httptest.NewRequest(http.MethodGet, "/openapi.json", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "application/json") {
t.Fatalf("expected JSON content type, got %q", contentType)
}
if !strings.Contains(w.Body.String(), `"swagger": "2.0"`) {
t.Fatalf("expected swagger document, got %q", w.Body.String())
}
}
func TestHealthEndpoint(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
req := httptest.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
body := w.Body.String()
if body != "ok" {
t.Errorf("expected body 'ok', got %q", body)
}
}
func TestStatsEndpoint(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
req := httptest.NewRequest("GET", "/stats", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
contentType := w.Header().Get("Content-Type")
if contentType != "application/json" {
t.Errorf("expected Content-Type application/json, got %q", contentType)
}
var stats StatsResponse
if err := json.NewDecoder(w.Body).Decode(&stats); err != nil {
t.Fatalf("failed to decode stats: %v", err)
}
if stats.CachedArtifacts != 0 {
t.Errorf("expected 0 cached artifacts, got %d", stats.CachedArtifacts)
}
if !strings.HasPrefix(stats.StorageURL, "file://") {
t.Errorf("expected storage_url to start with file://, got %q", stats.StorageURL)
}
}
func TestDashboard(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
contentType := w.Header().Get("Content-Type")
if !strings.HasPrefix(contentType, "text/html") {
t.Errorf("expected Content-Type text/html, got %q", contentType)
}
body := w.Body.String()
if body == "" {
t.Fatal("dashboard returned empty body")
}
if !strings.Contains(body, "git-pkgs proxy") {
t.Logf("Body: %s", body[:min(len(body), 500)])
t.Error("dashboard should contain title")
}
if !strings.Contains(body, "Cached Artifacts") {
t.Error("dashboard should contain stats")
}
if !strings.Contains(body, "Popular Packages") {
t.Error("dashboard should contain popular packages section")
}
if !strings.Contains(body, ">composer<") {
t.Error("dashboard should show composer in supported ecosystems")
}
if !strings.Contains(body, ">conan<") {
t.Error("dashboard should show conan in supported ecosystems")
}
if !strings.Contains(body, ">container<") {
t.Error("dashboard should show container in supported ecosystems")
}
if !strings.Contains(body, ">debian<") {
t.Error("dashboard should show debian in supported ecosystems")
}
if !strings.Contains(body, "/openapi.json") {
t.Error("page should link to the OpenAPI JSON spec")
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func TestNPMPackageMetadata(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
// This will fail to fetch from upstream (no network in test),
// but we can verify the handler is mounted and responds
req := httptest.NewRequest("GET", "/npm/lodash", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
// Should get a bad gateway since we can't reach npm
// The important thing is that the handler is mounted
if w.Code == http.StatusNotFound {
t.Error("npm handler should be mounted")
}
}
func TestCargoConfig(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
req := httptest.NewRequest("GET", "/cargo/config.json", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var config map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&config); err != nil {
t.Fatalf("failed to decode cargo config: %v", err)
}
if _, ok := config["dl"]; !ok {
t.Error("cargo config should have 'dl' field")
}
}
func TestGoList(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
// Test the /@v/list endpoint - should reach the handler even if upstream fails
req := httptest.NewRequest("GET", "/go/example.com/test/@v/list", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
// The handler is mounted if we get a response from the proxy (404 from upstream
// or 502 from connection failure), not a chi router 404.
// With metadata caching, upstream 404 is cleanly returned as our own 404.
if w.Code == http.StatusNotFound {
body := w.Body.String()
if !strings.Contains(body, "not found") {
t.Errorf("go handler should be mounted, got status %d, body: %s", w.Code, body)
}
}
}
func TestPyPISimple(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
req := httptest.NewRequest("GET", "/pypi/simple/requests/", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code == http.StatusNotFound {
t.Error("pypi handler should be mounted")
}
}
func TestGemSpecs(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
req := httptest.NewRequest("GET", "/gem/specs.4.8.gz", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code == http.StatusNotFound {
t.Error("gem handler should be mounted")
}
}
func TestStaticFiles(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
tests := []struct {
path string
contentTypes []string
}{
{"/static/tailwind.js", []string{"text/javascript", "application/javascript"}},
{"/static/style.css", []string{"text/css"}},
}
for _, tc := range tests {
req := httptest.NewRequest("GET", tc.path, nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("%s: expected status 200, got %d", tc.path, w.Code)
}
contentType := w.Header().Get("Content-Type")
found := false
for _, ct := range tc.contentTypes {
if strings.Contains(contentType, ct) {
found = true
break
}
}
if !found {
t.Errorf("%s: expected Content-Type containing one of %v, got %q", tc.path, tc.contentTypes, contentType)
}
}
}
func TestCategorizeLicenseCSS(t *testing.T) {
tests := []struct {
license string
expected string
}{
{"MIT", "permissive"},
{"Apache-2.0", "permissive"},
{"BSD-3-Clause", "permissive"},
{"ISC", "permissive"},
{"GPL-3.0", "copyleft"},
{"AGPL-3.0", "copyleft"},
{"LGPL-2.1", "copyleft"},
{"MPL-2.0", "copyleft"},
{"", "unknown"},
{"Proprietary", "unknown"},
}
for _, tc := range tests {
result := categorizeLicenseCSS(tc.license)
if result != tc.expected {
t.Errorf("categorizeLicenseCSS(%q) = %q, want %q", tc.license, result, tc.expected)
}
}
}
func TestDashboardWithEnrichmentStats(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
body := w.Body.String()
// Dashboard should link to Tailwind JS
if !strings.Contains(body, "/static/tailwind.js") {
t.Error("dashboard should link to Tailwind JS")
}
// Dashboard should have dark mode toggle
if !strings.Contains(body, "theme-toggle") {
t.Error("dashboard should have dark mode toggle")
}
}
func TestVersionShowWithHitCount(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
pkg := &database.Package{
PURL: "pkg:npm/test",
Ecosystem: "npm",
Name: "test",
}
if err := ts.db.UpsertPackage(pkg); err != nil {
t.Fatalf("failed to upsert package: %v", err)
}
ver := &database.Version{
PURL: "pkg:npm/test@1.0.0",
PackagePURL: pkg.PURL,
}
if err := ts.db.UpsertVersion(ver); err != nil {
t.Fatalf("failed to upsert version: %v", err)
}
artifact := &database.Artifact{
VersionPURL: ver.PURL,
Filename: "test-1.0.0.tgz",
UpstreamURL: "https://registry.npmjs.org/test/-/test-1.0.0.tgz",
HitCount: 42,
}
if err := ts.db.UpsertArtifact(artifact); err != nil {
t.Fatalf("failed to upsert artifact: %v", err)
}
req := httptest.NewRequest("GET", "/package/npm/test/1.0.0", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "42 cache hits") {
t.Error("expected page to show hit count")
}
}
func TestSearchWithNullValues(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
pkg := &database.Package{
PURL: "pkg:npm/test-pkg",
Ecosystem: "npm",
Name: "test-pkg",
}
if err := ts.db.UpsertPackage(pkg); err != nil {
t.Fatalf("failed to upsert package: %v", err)
}
ver := &database.Version{
PURL: "pkg:npm/test-pkg@1.0.0",
PackagePURL: pkg.PURL,
}
if err := ts.db.UpsertVersion(ver); err != nil {
t.Fatalf("failed to upsert version: %v", err)
}
storagePath := filepath.Join(ts.tempDir, "test.tgz")
if err := os.WriteFile(storagePath, []byte("test content"), 0644); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
artifact := &database.Artifact{
VersionPURL: ver.PURL,
Filename: "test-pkg-1.0.0.tgz",
UpstreamURL: "https://registry.npmjs.org/test-pkg/-/test-pkg-1.0.0.tgz",
StoragePath: sql.NullString{String: storagePath, Valid: true},
HitCount: 5,
}
if err := ts.db.UpsertArtifact(artifact); err != nil {
t.Fatalf("failed to upsert artifact: %v", err)
}
req := httptest.NewRequest("GET", "/search?q=test", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "test-pkg") {
t.Error("expected search results to contain package name")
}
}
func TestFormatTimeAgo_AllRanges(t *testing.T) {
tests := []struct {
name string
input time.Time
expected string
}{
{"zero time", time.Time{}, ""},
{"now", time.Now(), "just now"},
{"30 seconds ago", time.Now().Add(-30 * time.Second), "just now"},
{"1 minute ago", time.Now().Add(-1 * time.Minute), "1 min ago"},
{"5 minutes ago", time.Now().Add(-5 * time.Minute), "5 mins ago"},
{"1 hour ago", time.Now().Add(-1 * time.Hour), "1 hour ago"},
{"3 hours ago", time.Now().Add(-3 * time.Hour), "3 hours ago"},
{"1 day ago", time.Now().Add(-24 * time.Hour), "1 day ago"},
{"3 days ago", time.Now().Add(-3 * 24 * time.Hour), "3 days ago"},
{"10 days ago", time.Now().Add(-10 * 24 * time.Hour), time.Now().Add(-10 * 24 * time.Hour).Format("Jan 2")},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := formatTimeAgo(tc.input)
if got != tc.expected {
t.Errorf("formatTimeAgo() = %q, want %q", got, tc.expected)
}
})
}
}
func TestFormatSize_AllUnits(t *testing.T) {
tests := []struct {
bytes int64
expected string
}{
{0, "0 B"},
{500, "500 B"},
{1024, "1.0 KB"},
{1536, "1.5 KB"},
{1048576, "1.0 MB"},
{1073741824, "1.0 GB"},
}
for _, tc := range tests {
t.Run(tc.expected, func(t *testing.T) {
got := formatSize(tc.bytes)
if got != tc.expected {
t.Errorf("formatSize(%d) = %q, want %q", tc.bytes, got, tc.expected)
}
})
}
}
func TestCategorizeLicense_NullString(t *testing.T) {
tests := []struct {
name string
license sql.NullString
expected string
}{
{"invalid null string", sql.NullString{Valid: false}, "unknown"},
{"MIT", sql.NullString{String: "MIT", Valid: true}, "permissive"},
{"GPL-3.0", sql.NullString{String: "GPL-3.0", Valid: true}, "copyleft"},
{"empty string", sql.NullString{String: "", Valid: true}, "unknown"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := categorizeLicense(tc.license)
if got != tc.expected {
t.Errorf("categorizeLicense(%v) = %q, want %q", tc.license, got, tc.expected)
}
})
}
}
func TestSearchRedirectsWhenEmpty(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
req := httptest.NewRequest("GET", "/search", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusSeeOther {
t.Errorf("expected status 303, got %d", w.Code)
}
loc := w.Header().Get("Location")
if loc != "/" {
t.Errorf("expected redirect to /, got %q", loc)
}
}
func TestPackageShowPage_NotFoundServer(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
req := httptest.NewRequest("GET", "/package/npm/nonexistent-srv", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d", w.Code)
}
}
func TestVersionShowPage_NotFoundServer(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
req := httptest.NewRequest("GET", "/package/npm/nonexistent-srv/1.0.0", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d", w.Code)
}
}
func TestPackageShowPage_WithLicense(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
pkg := &database.Package{
PURL: "pkg:npm/show-test-lic",
Ecosystem: "npm",
Name: "show-test-lic",
License: sql.NullString{String: "MIT", Valid: true},
}
if err := ts.db.UpsertPackage(pkg); err != nil {
t.Fatalf("failed to upsert package: %v", err)
}
ver := &database.Version{
PURL: "pkg:npm/show-test-lic@1.0.0",
PackagePURL: pkg.PURL,
}
if err := ts.db.UpsertVersion(ver); err != nil {
t.Fatalf("failed to upsert version: %v", err)
}
req := httptest.NewRequest("GET", "/package/npm/show-test-lic", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "show-test-lic") {
t.Error("expected page to contain the package name")
}
}
func TestComposerNamespacedPackageRoutes(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
// Seed two Composer packages with vendor/name format.
for _, p := range []struct {
purl, name, versionPURL string
}{
{"pkg:composer/monolog/monolog", "monolog/monolog", "pkg:composer/monolog/monolog@3.0.0"},
{"pkg:composer/symfony/console", "symfony/console", "pkg:composer/symfony/console@6.0.0"},
} {
if err := ts.db.UpsertPackage(&database.Package{
PURL: p.purl, Ecosystem: "composer", Name: p.name,
}); err != nil {
t.Fatalf("failed to upsert package %s: %v", p.name, err)
}
if err := ts.db.UpsertVersion(&database.Version{
PURL: p.versionPURL, PackagePURL: p.purl,
}); err != nil {
t.Fatalf("failed to upsert version for %s: %v", p.name, err)
}
}
tests := []struct {
name string
url string
want string
}{
{"package show", "/package/composer/monolog/monolog", "monolog/monolog"},
{"version show", "/package/composer/symfony/console/6.0.0", "symfony/console"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", tt.url, nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("GET %s: expected status 200, got %d", tt.url, w.Code)
}
if !strings.Contains(w.Body.String(), tt.want) {
t.Errorf("GET %s: expected body to contain %q", tt.url, tt.want)
}
})
}
}
func TestNamespacedPackageRoutes(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
// Seed packages from ecosystems that use slashes in package names.
pkgs := []struct {
purl, ecosystem, name, versionPURL string
}{
// npm scoped packages
{"pkg:npm/%40babel/core", "npm", "@babel/core", "pkg:npm/%40babel/core@7.24.0"},
// Go modules (multi-segment paths)
{"pkg:golang/github.com/stretchr/testify", "golang", "github.com/stretchr/testify", "pkg:golang/github.com/stretchr/testify@1.9.0"},
// OCI/container images
{"pkg:oci/library/nginx", "oci", "library/nginx", "pkg:oci/library/nginx@sha256:abc123"},
// Conda (channel/name)
{"pkg:conda/conda-forge/numpy", "conda", "conda-forge/numpy", "pkg:conda/conda-forge/numpy@1.26.4"},
// Conan (name/version@user/channel)
{"pkg:conan/zlib/1.2.13@demo/stable", "conan", "zlib/1.2.13@demo/stable", "pkg:conan/zlib/1.2.13@demo/stable@rev1"},
}
for _, p := range pkgs {
if err := ts.db.UpsertPackage(&database.Package{
PURL: p.purl, Ecosystem: p.ecosystem, Name: p.name,
}); err != nil {
t.Fatalf("failed to upsert package %s: %v", p.name, err)
}
if err := ts.db.UpsertVersion(&database.Version{
PURL: p.versionPURL, PackagePURL: p.purl,
}); err != nil {
t.Fatalf("failed to upsert version for %s: %v", p.name, err)
}
}
tests := []struct {
name string
url string
want int
}{
{"npm scoped package show", "/package/npm/@babel/core", http.StatusOK},
{"golang module show", "/package/golang/github.com/stretchr/testify", http.StatusOK},
{"oci image show", "/package/oci/library/nginx", http.StatusOK},
{"conda package show", "/package/conda/conda-forge/numpy", http.StatusOK},
{"conan package show", "/package/conan/zlib/1.2.13@demo/stable", http.StatusOK},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", tt.url, nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != tt.want {
t.Errorf("GET %s: expected status %d, got %d (body: %s)",
tt.url, tt.want, w.Code, w.Body.String())
}
})
}
}
func TestSearchPage_WithSeededResults(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
seedTestPackage(t, ts.db, "searchable-pkg")
req := httptest.NewRequest("GET", "/search?q=searchable", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "searchable-pkg") {
t.Error("expected search results to contain package name")
}
}
func TestSearchPage_PaginationMultiPage(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
// Seed 55 packages to exceed one page (limit=50)
for i := 0; i < 55; i++ {
name := fmt.Sprintf("page-test-%03d", i)
pkg := &database.Package{
PURL: fmt.Sprintf("pkg:npm/%s", name),
Ecosystem: "npm",
Name: name,
}
if err := ts.db.UpsertPackage(pkg); err != nil {
t.Fatalf("failed to upsert package %d: %v", i, err)
}
ver := &database.Version{
PURL: fmt.Sprintf("pkg:npm/%s@1.0.0", name),
PackagePURL: pkg.PURL,
}
if err := ts.db.UpsertVersion(ver); err != nil {
t.Fatalf("failed to upsert version %d: %v", i, err)
}
artifact := &database.Artifact{
VersionPURL: ver.PURL,
Filename: fmt.Sprintf("%s-1.0.0.tgz", name),
UpstreamURL: fmt.Sprintf("https://registry.npmjs.org/%s/-/%s-1.0.0.tgz", name, name),
StoragePath: sql.NullString{String: "/tmp/test.tgz", Valid: true},
}
if err := ts.db.UpsertArtifact(artifact); err != nil {
t.Fatalf("failed to upsert artifact %d: %v", i, err)
}
}
// First page
req := httptest.NewRequest("GET", "/search?q=page-test", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "page-test-") {
t.Error("expected first page to contain results")
}
// Second page
req = httptest.NewRequest("GET", "/search?q=page-test&page=2", nil)
w = httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200 for page 2, got %d", w.Code)
}
}
func TestSearchPage_EcosystemFilterWithSeededData(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
// Seed npm package
npmPkg := &database.Package{
PURL: "pkg:npm/eco-filter-npm",
Ecosystem: "npm",
Name: "eco-filter-npm",
}
if err := ts.db.UpsertPackage(npmPkg); err != nil {
t.Fatalf("failed to upsert npm package: %v", err)
}
npmVer := &database.Version{
PURL: "pkg:npm/eco-filter-npm@1.0.0",
PackagePURL: npmPkg.PURL,
}
if err := ts.db.UpsertVersion(npmVer); err != nil {
t.Fatalf("failed to upsert npm version: %v", err)
}
npmArt := &database.Artifact{
VersionPURL: npmVer.PURL,
Filename: "eco-filter-npm-1.0.0.tgz",
UpstreamURL: "https://registry.npmjs.org/eco-filter-npm/-/eco-filter-npm-1.0.0.tgz",
StoragePath: sql.NullString{String: "/tmp/test.tgz", Valid: true},
}
if err := ts.db.UpsertArtifact(npmArt); err != nil {
t.Fatalf("failed to upsert npm artifact: %v", err)
}
// Seed pypi package
pypiPkg := &database.Package{
PURL: "pkg:pypi/eco-filter-pypi",
Ecosystem: "pypi",
Name: "eco-filter-pypi",
}
if err := ts.db.UpsertPackage(pypiPkg); err != nil {
t.Fatalf("failed to upsert pypi package: %v", err)
}
pypiVer := &database.Version{
PURL: "pkg:pypi/eco-filter-pypi@1.0.0",
PackagePURL: pypiPkg.PURL,
}
if err := ts.db.UpsertVersion(pypiVer); err != nil {
t.Fatalf("failed to upsert pypi version: %v", err)
}
pypiArt := &database.Artifact{
VersionPURL: pypiVer.PURL,
Filename: "eco-filter-pypi-1.0.0.tar.gz",
UpstreamURL: "https://files.pythonhosted.org/eco-filter-pypi-1.0.0.tar.gz",
StoragePath: sql.NullString{String: "/tmp/test.tar.gz", Valid: true},
}
if err := ts.db.UpsertArtifact(pypiArt); err != nil {
t.Fatalf("failed to upsert pypi artifact: %v", err)
}
// Search with ecosystem filter for npm only
req := httptest.NewRequest("GET", "/search?q=eco-filter&ecosystem=npm", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "eco-filter-npm") {
t.Error("expected npm package in filtered results")
}
if strings.Contains(body, "eco-filter-pypi") {
t.Error("did not expect pypi package in npm-filtered results")
}
}
func TestHandlePackagesListPage(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
seedTestPackage(t, ts.db, "list-test")
req := httptest.NewRequest("GET", "/packages", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "list-test") {
t.Error("expected packages list to contain seeded package")
}
}
func TestNewServer_StorageConnectivityCheck(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test.db")
storagePath := filepath.Join(tempDir, "artifacts")
cfg := &config.Config{
Listen: ":0",
BaseURL: "http://localhost:8080",
Storage: config.StorageConfig{URL: "file://" + storagePath},
Database: config.DatabaseConfig{Path: dbPath},
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() failed: %v", err)
}
// On Windows, OpenBucket normalises to file:///C:/path; on Unix the
// absolute path already starts with /, so file:// + /path == file:///path.
wantPrefix := "file://"
wantPath := filepath.ToSlash(storagePath)
got := srv.storage.URL()
if !strings.HasPrefix(got, wantPrefix) || !strings.Contains(got, wantPath) {
t.Errorf("expected storage URL containing %s, got %s", wantPath, got)
}
_ = srv.db.Close()
}
func TestStatsEndpoint_StorageURL(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
req := httptest.NewRequest("GET", "/stats", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
// Verify the JSON response uses storage_url (not storage_path)
body := w.Body.String()
if !strings.Contains(body, `"storage_url"`) {
t.Errorf("expected JSON key storage_url in response, got: %s", body)
}
if strings.Contains(body, `"storage_path"`) {
t.Errorf("unexpected JSON key storage_path in response (should be storage_url)")
}
}