pkg-proxy/internal/server/server_test.go

1026 lines
28 KiB
Go
Raw Permalink Normal View History

2026-01-20 21:52:44 +00:00
package server
import (
2026-02-03 22:40:23 +00:00
"database/sql"
2026-01-20 21:52:44 +00:00
"encoding/json"
"fmt"
2026-01-20 21:52:44 +00:00
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
2026-01-20 21:52:44 +00:00
"github.com/git-pkgs/proxy/internal/config"
2026-04-19 07:27:30 -04:00
"github.com/git-pkgs/proxy/internal/config/cargo"
2026-01-20 21:52:44 +00:00
"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"
2026-02-03 22:40:23 +00:00
"github.com/go-chi/chi/v5"
2026-01-20 21:52:44 +00:00
)
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()
2026-01-20 21:52:44 +00:00
proxy := handler.NewProxy(db, store, fetcher, resolver, logger)
cfg := &config.Config{
BaseURL: "http://localhost:8080",
Storage: config.StorageConfig{Path: storagePath},
2026-01-20 21:52:44 +00:00
Database: config.DatabaseConfig{Path: dbPath},
}
2026-02-03 22:40:23 +00:00
r := chi.NewRouter()
2026-01-20 21:52:44 +00:00
// Mount handlers
npmHandler := handler.NewNPMHandler(proxy, cfg.BaseURL)
2026-04-19 07:27:30 -04:00
cargoHandler := handler.NewCargoHandler(proxy, cfg.BaseURL, cargo.RouteDefault)
2026-01-20 21:52:44 +00:00
gemHandler := handler.NewGemHandler(proxy, cfg.BaseURL)
goHandler := handler.NewGoHandler(proxy, cfg.BaseURL)
pypiHandler := handler.NewPyPIHandler(proxy, cfg.BaseURL)
2026-02-03 22:40:23 +00:00
r.Mount("/npm", http.StripPrefix("/npm", npmHandler.Routes()))
2026-04-19 07:27:30 -04:00
r.Mount(cargoHandler.Path(), http.StripPrefix(cargoHandler.Path(), cargoHandler.Routes()))
2026-02-03 22:40:23 +00:00
r.Mount("/gem", http.StripPrefix("/gem", gemHandler.Routes()))
r.Mount("/go", http.StripPrefix("/go", goHandler.Routes()))
r.Mount("/pypi", http.StripPrefix("/pypi", pypiHandler.Routes()))
2026-01-20 21:52:44 +00:00
// Create a minimal server struct for the handlers
s := &Server{
2026-02-03 22:40:23 +00:00
cfg: cfg,
db: db,
storage: store,
logger: logger,
templates: &Templates{},
2026-01-20 21:52:44 +00:00
}
2026-02-03 22:40:23 +00:00
r.Get("/health", s.handleHealth)
r.Get("/stats", s.handleStats)
2026-03-11 17:18:29 +00:00
r.Get("/openapi.json", s.handleOpenAPIJSON)
2026-02-03 22:40:23 +00:00
r.Mount("/static", http.StripPrefix("/static/", staticHandler()))
r.Get("/search", s.handleSearch)
Fix Composer minified metadata expansion and namespaced package routing (#63) * Fix Composer minified metadata expansion and namespaced package routing Packagist serves metadata in a minified format where only the first version entry has all fields and subsequent entries inherit from the previous one. The proxy was passing this through without expanding it, which meant cooldown filtering could break the inheritance chain (losing fields like `name`) and `~dev` sentinel markers were silently dropped. The proxy now expands the minified format before filtering and rewriting, ensuring every version entry is self-contained. Web UI and API routes used single-segment chi URL params for package names, which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog` would match the version show route instead of the package show route. All `/package/` and related API routes now use wildcard paths with a `resolvePackageName` helper that tries increasingly longer path prefixes as package names via DB lookup, correctly handling namespaced packages across all endpoints (show, version, browse, compare, vulns). Fixes #61, fixes #62 * Add namespaced package routing tests for all affected ecosystems Verifies the wildcard routing handles slashes in package names for npm (@babel/core), Go modules (github.com/stretchr/testify), OCI images (library/nginx), Conda (conda-forge/numpy), and Conan (zlib/1.2.13@demo/stable). * Regenerate swagger docs after route refactor The swagger annotations for the old per-endpoint handlers were removed during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
r.Get("/package/{ecosystem}/*", s.handlePackagePath)
r.Get("/api/browse/{ecosystem}/*", s.handleBrowsePath)
r.Get("/api/compare/{ecosystem}/*", s.handleComparePath)
2026-02-03 22:40:23 +00:00
r.Get("/", s.handleRoot)
r.Get("/install", s.handleInstall)
r.Get("/packages", s.handlePackagesList)
2026-01-20 21:52:44 +00:00
return &testServer{
2026-02-03 22:40:23 +00:00
handler: r,
2026-01-20 21:52:44 +00:00
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)
}
}
2026-03-11 17:18:29 +00:00
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())
}
}
2026-01-20 21:52:44 +00:00
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)
}
2026-01-20 21:52:44 +00:00
}
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()
2026-02-03 22:40:23 +00:00
if body == "" {
t.Fatal("dashboard returned empty body")
}
2026-01-20 21:52:44 +00:00
if !strings.Contains(body, "git-pkgs proxy") {
2026-02-03 22:40:23 +00:00
t.Logf("Body: %s", body[:min(len(body), 500)])
2026-01-20 21:52:44 +00:00
t.Error("dashboard should contain title")
}
if !strings.Contains(body, "Cached Artifacts") {
t.Error("dashboard should contain stats")
}
2026-02-03 22:40:23 +00:00
if !strings.Contains(body, "Popular Packages") {
t.Error("dashboard should contain popular packages section")
2026-01-20 21:52:44 +00:00
}
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")
}
2026-03-11 17:18:29 +00:00
if !strings.Contains(body, "/openapi.json") {
t.Error("page should link to the OpenAPI JSON spec")
}
2026-01-20 21:52:44 +00:00
}
2026-02-03 22:40:23 +00:00
func min(a, b int) int {
if a < b {
return a
}
return b
}
2026-01-20 21:52:44 +00:00
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)
}
2026-01-20 21:52:44 +00:00
}
}
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")
}
}
2026-02-03 22:40:23 +00:00
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")
}
}
Fix Composer minified metadata expansion and namespaced package routing (#63) * Fix Composer minified metadata expansion and namespaced package routing Packagist serves metadata in a minified format where only the first version entry has all fields and subsequent entries inherit from the previous one. The proxy was passing this through without expanding it, which meant cooldown filtering could break the inheritance chain (losing fields like `name`) and `~dev` sentinel markers were silently dropped. The proxy now expands the minified format before filtering and rewriting, ensuring every version entry is self-contained. Web UI and API routes used single-segment chi URL params for package names, which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog` would match the version show route instead of the package show route. All `/package/` and related API routes now use wildcard paths with a `resolvePackageName` helper that tries increasingly longer path prefixes as package names via DB lookup, correctly handling namespaced packages across all endpoints (show, version, browse, compare, vulns). Fixes #61, fixes #62 * Add namespaced package routing tests for all affected ecosystems Verifies the wildcard routing handles slashes in package names for npm (@babel/core), Go modules (github.com/stretchr/testify), OCI images (library/nginx), Conda (conda-forge/numpy), and Conan (zlib/1.2.13@demo/stable). * Regenerate swagger docs after route refactor The swagger annotations for the old per-endpoint handlers were removed during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
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)")
}
}