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"
|
Add handler download flow and server utility tests
Covers HTTP download paths for gem, hex, go, conda, cran, and maven
handlers with cache hit, invalid input, and upstream proxy scenarios.
Adds server tests for formatTimeAgo, formatSize, categorizeLicense,
LoggerMiddleware, search/pagination, and API packages list endpoint.
2026-03-17 20:31:54 +00:00
|
|
|
"fmt"
|
2026-01-20 21:52:44 +00:00
|
|
|
"io"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
Add handler download flow and server utility tests
Covers HTTP download paths for gem, hex, go, conda, cran, and maven
handlers with cache hit, invalid input, and upstream proxy scenarios.
Adds server tests for formatTimeAgo, formatSize, categorizeLicense,
LoggerMiddleware, search/pagination, and API packages list endpoint.
2026-03-17 20:31:54 +00:00
|
|
|
"time"
|
2026-01-20 21:52:44 +00:00
|
|
|
|
|
|
|
|
"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"
|
2026-02-20 08:47:14 +00:00
|
|
|
"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))
|
2026-02-20 08:47:14 +00:00
|
|
|
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{
|
2026-03-11 17:25:47 +00:00
|
|
|
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)
|
|
|
|
|
cargoHandler := handler.NewCargoHandler(proxy, cfg.BaseURL)
|
|
|
|
|
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()))
|
|
|
|
|
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()))
|
|
|
|
|
|
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,
|
2026-04-06 13:06:25 +01:00
|
|
|
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)
|
2026-03-13 17:05:14 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
// 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)
|
|
|
|
|
}
|
2026-04-03 14:06:51 +01:00
|
|
|
|
|
|
|
|
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
|
|
|
}
|
2026-03-11 17:25:47 +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)
|
|
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
// 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")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-29 19:35:15 +00:00
|
|
|
|
|
|
|
|
func TestStaticFiles(t *testing.T) {
|
|
|
|
|
ts := newTestServer(t)
|
|
|
|
|
defer ts.close()
|
|
|
|
|
|
|
|
|
|
tests := []struct {
|
2026-01-29 20:41:52 +00:00
|
|
|
path string
|
|
|
|
|
contentTypes []string
|
2026-01-29 19:35:15 +00:00
|
|
|
}{
|
2026-01-29 20:41:52 +00:00
|
|
|
{"/static/tailwind.js", []string{"text/javascript", "application/javascript"}},
|
|
|
|
|
{"/static/style.css", []string{"text/css"}},
|
2026-01-29 19:35:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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")
|
2026-01-29 20:41:52 +00:00
|
|
|
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)
|
2026-01-29 19:35:15 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
Add handler download flow and server utility tests
Covers HTTP download paths for gem, hex, go, conda, cran, and maven
handlers with cache hit, invalid input, and upstream proxy scenarios.
Adds server tests for formatTimeAgo, formatSize, categorizeLicense,
LoggerMiddleware, search/pagination, and API packages list endpoint.
2026-03-17 20:31:54 +00:00
|
|
|
|
|
|
|
|
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())
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Add handler download flow and server utility tests
Covers HTTP download paths for gem, hex, go, conda, cran, and maven
handlers with cache hit, invalid input, and upstream proxy scenarios.
Adds server tests for formatTimeAgo, formatSize, categorizeLicense,
LoggerMiddleware, search/pagination, and API packages list endpoint.
2026-03-17 20:31:54 +00:00
|
|
|
func TestSearchPage_WithSeededResults(t *testing.T) {
|
|
|
|
|
ts := newTestServer(t)
|
|
|
|
|
defer ts.close()
|
|
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
seedTestPackage(t, ts.db, "searchable-pkg")
|
Add handler download flow and server utility tests
Covers HTTP download paths for gem, hex, go, conda, cran, and maven
handlers with cache hit, invalid input, and upstream proxy scenarios.
Adds server tests for formatTimeAgo, formatSize, categorizeLicense,
LoggerMiddleware, search/pagination, and API packages list endpoint.
2026-03-17 20:31:54 +00:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
seedTestPackage(t, ts.db, "list-test")
|
Add handler download flow and server utility tests
Covers HTTP download paths for gem, hex, go, conda, cran, and maven
handlers with cache hit, invalid input, and upstream proxy scenarios.
Adds server tests for formatTimeAgo, formatSize, categorizeLicense,
LoggerMiddleware, search/pagination, and API packages list endpoint.
2026-03-17 20:31:54 +00:00
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-03 14:06:51 +01:00
|
|
|
|
|
|
|
|
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://"
|
2026-04-06 13:07:31 +01:00
|
|
|
wantPath := filepath.ToSlash(storagePath)
|
2026-04-03 14:06:51 +01:00
|
|
|
got := srv.storage.URL()
|
2026-04-06 13:07:31 +01:00
|
|
|
if !strings.HasPrefix(got, wantPrefix) || !strings.Contains(got, wantPath) {
|
|
|
|
|
t.Errorf("expected storage URL containing %s, got %s", wantPath, got)
|
2026-04-03 14:06:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_ = 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)")
|
|
|
|
|
}
|
|
|
|
|
}
|