mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-08-23 04:14:57 -04:00
* Bump go tool golangci-lint to v2.13.1
The .golangci.yml goconst.ignore-tests setting was added in v2.12.0
(golangci/golangci-lint#6480). On the previously pinned v2.10.1,
config verify fails with "additional properties 'ignore-tests' not
allowed" and the setting is silently ignored at run time, so goconst
counts test-file literals toward min-occurrences.
* Apply gofmt and CutSuffix simplification
- gofmt -w internal/server/health_test.go
- Replace HasSuffix+TrimSuffix with CutSuffix in ParseSize
* Remove dead code and migrate tests off legacy Filesystem storage
Migrate the three test call sites of storage.NewFilesystem to
storage.OpenBucket("file://...") and drop the deprecated
StorageConfig.Path field from test configs, then delete code that
deadcode reports as unreachable from cmd/proxy:
- internal/storage/filesystem.go and its tests
- storage.HashingReader
- enrichment.Service.BulkCheckVulnerabilities and NormalizeLicense
- server.ActiveRequestsMiddleware (no-op body; the real tracking
is the inline r.Use at server.go:226)
- mirror.RegistrySource (unimplemented stub)
metrics.UpdateCircuitBreakerState and RecordCircuitBreakerTrip are
kept because #275 wires them.
Update the CONTRIBUTING.md storage section to reflect blob.go.
1400 lines
41 KiB
Go
1400 lines
41 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"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/purl"
|
|
"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.OpenBucket(context.Background(), "file://"+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{URL: "file://" + storagePath},
|
|
Database: config.DatabaseConfig{Path: dbPath},
|
|
}
|
|
|
|
r := chi.NewRouter()
|
|
|
|
// Mount handlers
|
|
npmHandler := handler.NewNPMHandler(proxy, cfg.BaseURL, cfg.Upstream.NPM)
|
|
cargoHandler := handler.NewCargoHandler(
|
|
proxy,
|
|
cfg.BaseURL,
|
|
cfg.Upstream.Cargo,
|
|
cfg.Upstream.CargoDownload,
|
|
)
|
|
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,
|
|
buildInfo: BuildInfo{Version: "test-version", Commit: "test-commit"},
|
|
templates: &Templates{},
|
|
healthCache: hc,
|
|
}
|
|
|
|
r.Get("/health", s.handleHealth)
|
|
r.Get("/stats", s.handleStats)
|
|
r.Get("/openapi.json", s.handleOpenAPIJSON)
|
|
r.Route("/ui", func(ui chi.Router) {
|
|
ui.Mount("/static", http.StripPrefix("/ui/static/", staticHandler()))
|
|
ui.Get("/", s.handleRoot)
|
|
ui.Get("/install", s.handleInstall)
|
|
ui.Get("/search", s.handleSearch)
|
|
ui.Get("/packages", s.handlePackagesList)
|
|
ui.Get("/package/{ecosystem}/*", s.handlePackagePath)
|
|
ui.Get("/api/browse/{ecosystem}/*", s.handleBrowsePath)
|
|
ui.Get("/api/compare/{ecosystem}/*", s.handleComparePath)
|
|
})
|
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, "/ui/", http.StatusFound)
|
|
})
|
|
|
|
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", "/ui/", 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, "proxy test-version (test-commit)") {
|
|
t.Error("dashboard footer should contain build information")
|
|
}
|
|
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
|
|
}{
|
|
{"/ui/static/vendor/tailwind.js", []string{"text/javascript", "application/javascript"}},
|
|
{"/ui/static/vendor/lucide.min.js", []string{"text/javascript", "application/javascript"}},
|
|
{"/ui/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 TestRootRedirectsToUI(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.StatusFound {
|
|
t.Errorf("expected status 302, got %d", w.Code)
|
|
}
|
|
if loc := w.Header().Get("Location"); loc != "/ui/" {
|
|
t.Errorf("expected redirect to /ui/, got %q", loc)
|
|
}
|
|
}
|
|
|
|
func TestDashboardWithEnrichmentStats(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.close()
|
|
|
|
req := httptest.NewRequest("GET", "/ui/", 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, "/ui/static/vendor/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", "/ui/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")
|
|
}
|
|
if !strings.Contains(body, "proxy test-version (test-commit)") {
|
|
t.Error("version show footer should contain proxy build information, not the package version")
|
|
}
|
|
}
|
|
|
|
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", "/ui/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", "/ui/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 != "/ui/" {
|
|
t.Errorf("expected redirect to /ui/, got %q", loc)
|
|
}
|
|
}
|
|
|
|
func TestPackageShowPage_NotFoundServer(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.close()
|
|
|
|
req := httptest.NewRequest("GET", "/ui/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", "/ui/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)
|
|
}
|
|
}
|
|
|
|
// TestVersionShowPage_PlusInVersion covers Debian/Ubuntu style versions such as
|
|
// nmap's "7.91+dfsg1+really7.80+dfsg1-2ubuntu0.1". PURL percent-encodes "+" as
|
|
// "%2B", so the UI must show the decoded version and resolve both the decoded
|
|
// and the still-encoded form of the URL back to the same version.
|
|
func TestVersionShowPage_PlusInVersion(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.close()
|
|
|
|
const version = "7.91+dfsg1+really7.80+dfsg1-2ubuntu0.1"
|
|
const versionPURL = "pkg:deb/nmap@7.91%2Bdfsg1%2Breally7.80%2Bdfsg1-2ubuntu0.1"
|
|
|
|
pkg := &database.Package{PURL: "pkg:deb/nmap", Ecosystem: "deb", Name: "nmap"}
|
|
if err := ts.db.UpsertPackage(pkg); err != nil {
|
|
t.Fatalf("failed to upsert package: %v", err)
|
|
}
|
|
if err := ts.db.UpsertVersion(&database.Version{
|
|
PURL: versionPURL, PackagePURL: pkg.PURL,
|
|
}); err != nil {
|
|
t.Fatalf("failed to upsert version: %v", err)
|
|
}
|
|
|
|
// The package page must link to and display the decoded version.
|
|
req := httptest.NewRequest("GET", "/ui/package/deb/nmap", nil)
|
|
w := httptest.NewRecorder()
|
|
ts.handler.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("package page: expected status 200, got %d", w.Code)
|
|
}
|
|
body := w.Body.String()
|
|
if strings.Contains(body, "%2B") {
|
|
t.Error("package page leaks PURL percent-encoding into the UI")
|
|
}
|
|
// html/template renders "+" as the "+" entity inside attributes and text.
|
|
if !strings.Contains(body, "7.91+dfsg1+really7.80+dfsg1-2ubuntu0.1") {
|
|
t.Error("expected package page to show the decoded version")
|
|
}
|
|
|
|
// Both the decoded and the encoded URL must reach the version page.
|
|
for _, path := range []string{
|
|
"/ui/package/deb/nmap/" + version,
|
|
"/ui/package/deb/nmap/7.91%2Bdfsg1%2Breally7.80%2Bdfsg1-2ubuntu0.1",
|
|
} {
|
|
req := httptest.NewRequest("GET", path, nil)
|
|
w := httptest.NewRecorder()
|
|
ts.handler.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("GET %s: expected status 200, got %d", path, w.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestVersionURLEscaping covers versions whose characters are significant in a
|
|
// URL path: "/" splits off another path segment, "?" starts a query string, and
|
|
// a literal "%xx" is read back as the character it encodes. The pages show the
|
|
// decoded version but must build every link from a separately escaped value,
|
|
// and those links have to resolve back to the same version.
|
|
func TestVersionURLEscaping(t *testing.T) {
|
|
// A second version is needed for the compare controls to be rendered.
|
|
const otherVersion = "1.0.0"
|
|
|
|
tests := []struct {
|
|
name string
|
|
version string
|
|
}{
|
|
{"slash", "release/1"},
|
|
{"question mark", "v1?build"},
|
|
{"literal percent escape", "1.0%2B"},
|
|
{"plus", "7.91+dfsg1-2ubuntu0.1"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.close()
|
|
seedEscapingVersions(t, ts.db, tt.version, otherVersion)
|
|
|
|
// The package page links to the escaped version.
|
|
escaped := url.PathEscape(tt.version)
|
|
versionPath := "/ui/package/deb/nmap/" + escaped
|
|
packagePage := ts.getOK(t, "/ui/package/deb/nmap")
|
|
if !containsValue(attrValues(packagePage, "href"), versionPath) {
|
|
t.Fatalf("package page has no link to %q; hrefs: %v",
|
|
versionPath, attrValues(packagePage, "href"))
|
|
}
|
|
|
|
ts.checkVersionAndBrowsePages(t, versionPath, tt.version, escaped)
|
|
ts.checkComparePage(t, packagePage, tt.version, escaped, otherVersion)
|
|
})
|
|
}
|
|
}
|
|
|
|
// seedEscapingVersions stores a Debian package with the given versions, each
|
|
// with a cached artifact so that the version page offers its browse link.
|
|
func seedEscapingVersions(t *testing.T, db *database.DB, versions ...string) {
|
|
t.Helper()
|
|
|
|
pkg := &database.Package{PURL: "pkg:deb/nmap", Ecosystem: "deb", Name: "nmap"}
|
|
if err := db.UpsertPackage(pkg); err != nil {
|
|
t.Fatalf("failed to upsert package: %v", err)
|
|
}
|
|
for _, v := range versions {
|
|
versionPURL := purl.MakePURLString("deb", "nmap", v)
|
|
if err := db.UpsertVersion(&database.Version{
|
|
PURL: versionPURL, PackagePURL: pkg.PURL,
|
|
}); err != nil {
|
|
t.Fatalf("failed to upsert version %q: %v", v, err)
|
|
}
|
|
if err := db.UpsertArtifact(&database.Artifact{
|
|
VersionPURL: versionPURL,
|
|
Filename: "nmap.deb",
|
|
UpstreamURL: "http://archive.ubuntu.com/ubuntu/pool/universe/n/nmap/nmap.deb",
|
|
StoragePath: sql.NullString{String: "/cache/nmap.deb", Valid: true},
|
|
FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
|
}); err != nil {
|
|
t.Fatalf("failed to upsert artifact for %q: %v", v, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkVersionAndBrowsePages follows a version link from the package page and
|
|
// then the browse link from the version page, checking that both resolve to the
|
|
// stored version and display it decoded.
|
|
func (ts *testServer) checkVersionAndBrowsePages(t *testing.T, versionPath, version, escaped string) {
|
|
t.Helper()
|
|
|
|
versionPage := ts.getOK(t, versionPath)
|
|
wantPURL := "pkg:deb/nmap@" + version
|
|
if !strings.Contains(html.UnescapeString(versionPage), wantPURL) {
|
|
t.Errorf("version page does not show %q", wantPURL)
|
|
}
|
|
|
|
browsePath := versionPath + "/browse"
|
|
if !containsValue(attrValues(versionPage, "href"), browsePath) {
|
|
t.Fatalf("version page has no browse link to %q; hrefs: %v",
|
|
browsePath, attrValues(versionPage, "href"))
|
|
}
|
|
|
|
browsePage := ts.getOK(t, browsePath)
|
|
if !strings.Contains(html.UnescapeString(browsePage), "nmap@"+version) {
|
|
t.Errorf("browse page does not show the decoded version %q", version)
|
|
}
|
|
// The browse API is called with the escaped version, not with the text shown
|
|
// in the heading.
|
|
if got := jsConstant(t, browsePage, "versionPath"); got != escaped {
|
|
t.Errorf("browse page passes %q to the browse API, want %q", got, escaped)
|
|
}
|
|
}
|
|
|
|
// checkComparePage builds the compare URL the way the package page's script
|
|
// does, from the values its checkboxes carry, and checks the page it reaches.
|
|
func (ts *testServer) checkComparePage(t *testing.T, packagePage, version, escaped, otherVersion string) {
|
|
t.Helper()
|
|
|
|
selectable := attrValues(packagePage, "data-version-path")
|
|
if !containsValue(selectable, escaped) {
|
|
t.Fatalf("package page compare data holds %v, want %q", selectable, escaped)
|
|
}
|
|
|
|
comparePage := ts.getOK(t, "/ui/package/deb/nmap/compare/"+escaped+"..."+otherVersion)
|
|
decoded := html.UnescapeString(comparePage)
|
|
for _, want := range []string{version, otherVersion} {
|
|
if !strings.Contains(decoded, want) {
|
|
t.Errorf("compare page does not show version %q", want)
|
|
}
|
|
}
|
|
if got := jsConstant(t, comparePage, "fromVersionPath"); got != escaped {
|
|
t.Errorf("compare page passes %q to the compare API, want %q", got, escaped)
|
|
}
|
|
}
|
|
|
|
// getOK performs a GET against the server and fails the test unless it returns
|
|
// 200, returning the response body.
|
|
func (ts *testServer) getOK(t *testing.T, path string) string {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequest("GET", path, nil)
|
|
w := httptest.NewRecorder()
|
|
ts.handler.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GET %s: expected status 200, got %d", path, w.Code)
|
|
}
|
|
|
|
return w.Body.String()
|
|
}
|
|
|
|
// attrValues returns the value of every occurrence of an HTML attribute in a
|
|
// rendered page, with HTML entities resolved so that values can be compared
|
|
// against the raw strings they were built from.
|
|
func attrValues(body, attr string) []string {
|
|
re := regexp.MustCompile(regexp.QuoteMeta(attr) + `="([^"]*)"`)
|
|
|
|
var values []string
|
|
for _, match := range re.FindAllStringSubmatch(body, -1) {
|
|
values = append(values, html.UnescapeString(match[1]))
|
|
}
|
|
|
|
return values
|
|
}
|
|
|
|
// jsConstant returns the value of a single-quoted JavaScript string constant in
|
|
// a rendered page. html/template escapes characters that are significant in
|
|
// JavaScript, rendering "+" as "\\u002b" for instance, so the escapes are
|
|
// resolved to recover the value the page actually uses.
|
|
func jsConstant(t *testing.T, body, name string) string {
|
|
t.Helper()
|
|
|
|
re := regexp.MustCompile(`const ` + regexp.QuoteMeta(name) + ` = '([^']*)'`)
|
|
match := re.FindStringSubmatch(body)
|
|
if match == nil {
|
|
t.Fatalf("page does not declare the constant %q", name)
|
|
}
|
|
|
|
unescaped, err := strconv.Unquote(`"` + match[1] + `"`)
|
|
if err != nil {
|
|
t.Fatalf("cannot unescape %q: %v", match[1], err)
|
|
}
|
|
|
|
return unescaped
|
|
}
|
|
|
|
func containsValue(values []string, want string) bool {
|
|
for _, v := range values {
|
|
if v == want {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
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", "/ui/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", "/ui/package/composer/monolog/monolog", "monolog/monolog"},
|
|
{"version show", "/ui/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", "/ui/package/npm/@babel/core", http.StatusOK},
|
|
{"golang module show", "/ui/package/golang/github.com/stretchr/testify", http.StatusOK},
|
|
{"oci image show", "/ui/package/oci/library/nginx", http.StatusOK},
|
|
{"conda package show", "/ui/package/conda/conda-forge/numpy", http.StatusOK},
|
|
{"conan package show", "/ui/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", "/ui/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", "/ui/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", "/ui/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", "/ui/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", "/ui/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))
|
|
|
|
buildInfo := BuildInfo{Version: "test-version", Commit: "test-commit"}
|
|
srv, err := New(cfg, logger, buildInfo)
|
|
if err != nil {
|
|
t.Fatalf("New() failed: %v", err)
|
|
}
|
|
if srv.buildInfo != buildInfo {
|
|
t.Errorf("build info = %#v, want %#v", srv.buildInfo, buildInfo)
|
|
}
|
|
|
|
// 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 TestNewServer_InvalidAccessLogFailsBeforeDatabaseInit(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
dbPath := filepath.Join(tempDir, "test.db")
|
|
cfg := &config.Config{
|
|
Storage: config.StorageConfig{URL: "file://" + filepath.Join(tempDir, "artifacts")},
|
|
Database: config.DatabaseConfig{Path: dbPath},
|
|
AccessLog: config.AccessLogConfig{Path: filepath.Join(tempDir, "missing", "access.jsonl")},
|
|
}
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
if _, err := New(cfg, logger, BuildInfo{}); err == nil {
|
|
t.Fatal("New() succeeded with invalid access log path")
|
|
} else if !strings.Contains(err.Error(), "initializing access log") {
|
|
t.Fatalf("New() error = %v, want access log initialization error", err)
|
|
}
|
|
|
|
if _, err := os.Stat(dbPath); !os.IsNotExist(err) {
|
|
t.Errorf("database initialized before access log validation: %v", err)
|
|
}
|
|
}
|
|
|
|
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)")
|
|
}
|
|
}
|