mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-06-02 16:48:16 -04:00
* config: add Health.StorageProbeInterval * metrics: add proxy_health_probe_failures_total counter * server: add storageProbe with happy-path test * server: add storageProbe failure-mode tests * server: add healthCache with TTL, single-flight, transition logging * server: wire storage probe into /health * server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer Also fix Windows file-locking issue in storageProbe: close the reader explicitly before Delete so the file handle is released prior to os.Remove. * server: clean up stale comment in storageProbe * docs: document storage health probe and new metric * docs: regenerate Swagger for /health JSON response * server: simplify rc.Close error handling in storageProbe * server: defer probe cleanup so size/open/read/verify failures don't leak objects Previously, storageProbe only called Delete on the success path. Any failure between Store and the final Delete (size mismatch, Open error, mid-stream read failure, content mismatch) left the probe object orphaned in the storage backend. With caching disabled and Kubernetes-rate probing, the leak could accumulate noticeably on backends like S3. Use a named return + defer to attempt Delete after every successful Store. The earlier-step failure remains the primary error; Delete failure only surfaces as step="delete" when nothing else went wrong. Add a table-driven test that asserts cleanup runs for each non-delete failure path. Reported by Copilot on #119. * config: validate health.storage_probe_interval in Config.Validate The new duration field was only validated at use time in newHealthCache. The existing codebase already validates other duration fields (MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in Config.Validate() so misconfiguration fails fast at startup with a config-key-specific error. Match that pattern. The parse-at-use code in newHealthCache stays as a safety net, mirroring the MetadataTTL precedent. Reported by Copilot on #119. * docs: lowercase "counter" in metrics table for consistency Other rows in the table use lowercase type names (counter/gauge/histogram). Match that style. Reported by Copilot on #119. * docs: include size-check step in /health probe description The probe is write → size-check → read → verify → delete; the architecture note was missing the size-check step. Reported by Copilot on #119. * server: address andrew's review on #119 - Drop unused callerCtx parameter from healthCache.Check (Check is now parameter-less; the comment-only "accepted for symmetry" justification wasn't carrying its weight). - Emit "storage": {"status": "skipped"} on DB short-circuit instead of omitting the key, so monitors expecting a fixed key set keep working. - Reject negative storage_probe_interval at config validation time (previously parsed and silently behaved like "0"). - Extract HealthConfig.Validate to keep Config.Validate under the gocognit threshold and match the existing GradleBuildCacheConfig pattern. - README Health Check section: note that /health is intended as a readiness probe rather than a liveness probe (Check holds a mutex for up to the 10s probe timeout). - cmd/proxy/main.go godoc: column-align the new env var with the surrounding Gradle entries. Reported by andrew on #119.
1105 lines
30 KiB
Go
1105 lines
30 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)
|
|
gradleHandler := handler.NewGradleBuildCacheHandler(proxy)
|
|
|
|
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()))
|
|
r.Mount("/gradle", http.StripPrefix("/gradle", gradleHandler.Routes()))
|
|
|
|
hc, err := newHealthCache(store, "30s", logger)
|
|
if err != nil {
|
|
_ = db.Close()
|
|
_ = os.RemoveAll(tempDir)
|
|
t.Fatalf("failed to create health cache: %v", err)
|
|
}
|
|
|
|
// Create a minimal server struct for the handlers
|
|
s := &Server{
|
|
cfg: cfg,
|
|
db: db,
|
|
storage: store,
|
|
logger: logger,
|
|
templates: &Templates{},
|
|
healthCache: hc,
|
|
}
|
|
|
|
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.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
|
}
|
|
if got := w.Header().Get("Content-Type"); got != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", got)
|
|
}
|
|
var resp HealthResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decoding response: %v", err)
|
|
}
|
|
if resp.Status != "ok" {
|
|
t.Errorf("status = %q, want ok", resp.Status)
|
|
}
|
|
if resp.Checks["database"].Status != "ok" {
|
|
t.Errorf("database check = %+v, want ok", resp.Checks["database"])
|
|
}
|
|
if resp.Checks["storage"].Status != "ok" {
|
|
t.Errorf("storage check = %+v, want ok", resp.Checks["storage"])
|
|
}
|
|
}
|
|
|
|
func TestHealthEndpoint_DBFailureShortCircuits(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.close()
|
|
|
|
// Force DB failure by closing the connection.
|
|
_ = ts.db.Close()
|
|
|
|
req := httptest.NewRequest("GET", "/health", nil)
|
|
w := httptest.NewRecorder()
|
|
ts.handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status = %d, want 503; body: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp HealthResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decoding: %v", err)
|
|
}
|
|
if resp.Status != "error" {
|
|
t.Errorf("status = %q, want error", resp.Status)
|
|
}
|
|
if resp.Checks["database"].Status != "error" {
|
|
t.Errorf("database check = %+v, want error", resp.Checks["database"])
|
|
}
|
|
storage, present := resp.Checks["storage"]
|
|
if !present {
|
|
t.Error("storage key should be present (with status=skipped) on DB short-circuit")
|
|
} else if storage.Status != "skipped" {
|
|
t.Errorf("storage check = %+v, want status=skipped", storage)
|
|
}
|
|
}
|
|
|
|
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 TestGradleBuildCachePutGet(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.close()
|
|
|
|
key := "abc123def456"
|
|
body := "build-cache-bytes"
|
|
|
|
putReq := httptest.NewRequest(http.MethodPut, "/gradle/"+key, strings.NewReader(body))
|
|
putW := httptest.NewRecorder()
|
|
ts.handler.ServeHTTP(putW, putReq)
|
|
|
|
if putW.Code != http.StatusCreated {
|
|
t.Fatalf("expected status 201, got %d: %s", putW.Code, putW.Body.String())
|
|
}
|
|
|
|
getReq := httptest.NewRequest(http.MethodGet, "/gradle/"+key, nil)
|
|
getW := httptest.NewRecorder()
|
|
ts.handler.ServeHTTP(getW, getReq)
|
|
|
|
if getW.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d: %s", getW.Code, getW.Body.String())
|
|
}
|
|
if got := getW.Body.String(); got != body {
|
|
t.Fatalf("expected body %q, got %q", body, got)
|
|
}
|
|
}
|
|
|
|
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)")
|
|
}
|
|
}
|