mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-07-07 06:43:20 -04:00
Adds UIBaseURL (env PROXY_UI_URL), advertised separately from BaseURL for deployments where the UI is reached on a public domain while build machines hit a Docker network alias for the package endpoints. Defaults to BaseURL when unset. Consumers: - Logged alongside base_url at startup. - <link rel="canonical"> and og:url / og:title / og:site_name in every UI page, omitted when UIBaseURL is empty. - Banner on the install guide when UIBaseURL differs from BaseURL, clarifying that the URLs in the snippets are the package endpoint and that the UI itself lives elsewhere. Docs call out that the proxy serves UI and package endpoints on the same listener, so reverse proxies fronting the UI publicly must restrict the public route to PathPrefix(/ui) to avoid exposing /npm, /pypi, etc. nginx and Traefik examples both show the path-split pattern.
549 lines
14 KiB
Go
549 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"database/sql"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/git-pkgs/proxy/internal/database"
|
|
)
|
|
|
|
func TestTemplatesRenderAllPages(t *testing.T) {
|
|
templates := &Templates{}
|
|
|
|
tests := []struct {
|
|
page string
|
|
data any
|
|
}{
|
|
{"dashboard", DashboardData{
|
|
Stats: DashboardStats{
|
|
CachedArtifacts: 42,
|
|
TotalSize: "1.5 GB",
|
|
TotalPackages: 10,
|
|
TotalVersions: 25,
|
|
},
|
|
EnrichmentStats: EnrichmentStatsView{
|
|
EnrichedPackages: 5,
|
|
TotalVulnerabilities: 3,
|
|
CriticalVulns: 1,
|
|
HasVulns: true,
|
|
},
|
|
PopularPackages: []PackageInfo{
|
|
{Ecosystem: "npm", Name: "lodash", Hits: 100, Size: "500 KB", License: "MIT", LicenseCategory: "permissive"},
|
|
},
|
|
RecentPackages: []PackageInfo{
|
|
{Ecosystem: "cargo", Name: "serde", Version: "1.0.0", Size: "200 KB", CachedAt: "1 hour ago"},
|
|
},
|
|
}},
|
|
{"install", struct {
|
|
Layout
|
|
BaseURL string
|
|
Registries []RegistryConfig
|
|
}{
|
|
BaseURL: "http://localhost:8080",
|
|
Registries: getRegistryConfigs("http://localhost:8080"),
|
|
}},
|
|
{"search", SearchPageData{
|
|
Query: "lodash",
|
|
Ecosystem: "npm",
|
|
Results: []SearchResultItem{{Ecosystem: "npm", Name: "lodash", LatestVersion: "4.17.21", Hits: 50, SizeFormatted: "1 MB"}},
|
|
Count: 1,
|
|
Page: 1,
|
|
PerPage: 50,
|
|
TotalPages: 1,
|
|
}},
|
|
{"search", SearchPageData{
|
|
Query: "nothing",
|
|
Results: []SearchResultItem{},
|
|
Count: 0,
|
|
Page: 1,
|
|
PerPage: 50,
|
|
TotalPages: 0,
|
|
}},
|
|
{"packages_list", PackagesListPageData{
|
|
Ecosystem: "",
|
|
SortBy: defaultSortBy,
|
|
Results: []SearchResultItem{{Ecosystem: "npm", Name: "express", Hits: 200, SizeFormatted: "2 MB"}},
|
|
Count: 1,
|
|
Page: 1,
|
|
PerPage: 50,
|
|
TotalPages: 1,
|
|
}},
|
|
{"package_show", PackageShowData{
|
|
Package: &database.Package{
|
|
PURL: "pkg:npm/lodash",
|
|
Ecosystem: "npm",
|
|
Name: "lodash",
|
|
LatestVersion: sql.NullString{String: "4.17.21", Valid: true},
|
|
License: sql.NullString{String: "MIT", Valid: true},
|
|
},
|
|
Versions: []database.Version{
|
|
{PURL: "pkg:npm/lodash@4.17.21", PackagePURL: "pkg:npm/lodash"},
|
|
},
|
|
Vulnerabilities: []database.Vulnerability{},
|
|
LicenseCategory: "permissive",
|
|
}},
|
|
{"package_show", PackageShowData{
|
|
Package: &database.Package{
|
|
PURL: "pkg:npm/minimal",
|
|
Ecosystem: "npm",
|
|
Name: "minimal",
|
|
},
|
|
Versions: []database.Version{},
|
|
Vulnerabilities: []database.Vulnerability{},
|
|
LicenseCategory: "unknown",
|
|
}},
|
|
{"version_show", VersionShowData{
|
|
Package: &database.Package{
|
|
PURL: "pkg:npm/lodash",
|
|
Ecosystem: "npm",
|
|
Name: "lodash",
|
|
LatestVersion: sql.NullString{String: "4.17.21", Valid: true},
|
|
License: sql.NullString{String: "MIT", Valid: true},
|
|
},
|
|
Version: &database.Version{
|
|
PURL: "pkg:npm/lodash@4.17.20",
|
|
PackagePURL: "pkg:npm/lodash",
|
|
},
|
|
Artifacts: []database.Artifact{
|
|
{
|
|
VersionPURL: "pkg:npm/lodash@4.17.20",
|
|
Filename: "lodash-4.17.20.tgz",
|
|
StoragePath: sql.NullString{String: "npm/lodash/4.17.20/lodash-4.17.20.tgz", Valid: true},
|
|
Size: sql.NullInt64{Int64: 1024, Valid: true},
|
|
HitCount: 42,
|
|
},
|
|
},
|
|
Vulnerabilities: []database.Vulnerability{},
|
|
IsOutdated: true,
|
|
LicenseCategory: "permissive",
|
|
HasCachedArtifact: true,
|
|
}},
|
|
{"browse_source", BrowseSourceData{
|
|
Ecosystem: "npm",
|
|
PackageName: "lodash",
|
|
Version: "4.17.21",
|
|
}},
|
|
{"compare_versions", ComparePageData{
|
|
Ecosystem: "npm",
|
|
PackageName: "lodash",
|
|
FromVersion: "4.17.20",
|
|
ToVersion: "4.17.21",
|
|
}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.page, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
err := templates.Render(w, tt.page, tt.data)
|
|
if err != nil {
|
|
t.Fatalf("Render(%q) failed: %v", tt.page, err)
|
|
}
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
|
|
}
|
|
if w.Header().Get("Content-Type") != "text/html; charset=utf-8" {
|
|
t.Errorf("Content-Type = %q, want text/html", w.Header().Get("Content-Type"))
|
|
}
|
|
body := w.Body.String()
|
|
if body == "" {
|
|
t.Error("rendered page is empty")
|
|
}
|
|
if !strings.Contains(body, "<!DOCTYPE html>") && !strings.Contains(body, "<html") {
|
|
t.Error("rendered page doesn't look like HTML")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRenderEmitsCanonicalAndOG(t *testing.T) {
|
|
templates := &Templates{}
|
|
|
|
data := DashboardData{
|
|
Layout: Layout{
|
|
UIBaseURL: "https://ui.example.com/ui",
|
|
CanonicalPath: "/ui/",
|
|
},
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
if err := templates.Render(w, "dashboard", data); err != nil {
|
|
t.Fatalf("Render failed: %v", err)
|
|
}
|
|
|
|
body := w.Body.String()
|
|
want := []string{
|
|
`<link rel="canonical" href="https://ui.example.com/ui/ui/">`,
|
|
`<meta property="og:url" content="https://ui.example.com/ui/ui/">`,
|
|
`<meta property="og:site_name" content="git-pkgs proxy">`,
|
|
}
|
|
for _, s := range want {
|
|
if !strings.Contains(body, s) {
|
|
t.Errorf("rendered body missing %q", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRenderOmitsCanonicalWhenUIBaseURLUnset(t *testing.T) {
|
|
templates := &Templates{}
|
|
|
|
w := httptest.NewRecorder()
|
|
if err := templates.Render(w, "dashboard", DashboardData{}); err != nil {
|
|
t.Fatalf("Render failed: %v", err)
|
|
}
|
|
|
|
body := w.Body.String()
|
|
if strings.Contains(body, `rel="canonical"`) {
|
|
t.Error("canonical tag should be omitted when UIBaseURL is empty")
|
|
}
|
|
if strings.Contains(body, `property="og:url"`) {
|
|
t.Error("og:url tag should be omitted when UIBaseURL is empty")
|
|
}
|
|
}
|
|
|
|
func TestInstallPageBannerWhenUIDiffersFromBaseURL(t *testing.T) {
|
|
templates := &Templates{}
|
|
|
|
data := struct {
|
|
Layout
|
|
BaseURL string
|
|
Registries []RegistryConfig
|
|
}{
|
|
Layout: Layout{
|
|
UIBaseURL: "https://ui.example.com/ui",
|
|
CanonicalPath: "/ui/install",
|
|
},
|
|
BaseURL: "http://pkg-proxy:8080",
|
|
Registries: getRegistryConfigs("http://pkg-proxy:8080"),
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
if err := templates.Render(w, "install", data); err != nil {
|
|
t.Fatalf("Render failed: %v", err)
|
|
}
|
|
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, "https://ui.example.com/ui") || !strings.Contains(body, "http://pkg-proxy:8080") {
|
|
t.Error("install banner should mention both UIBaseURL and BaseURL when they differ")
|
|
}
|
|
if !strings.Contains(body, "package managers should be configured") {
|
|
t.Error("install banner copy missing")
|
|
}
|
|
}
|
|
|
|
func TestInstallPageNoBannerWhenURLsMatch(t *testing.T) {
|
|
templates := &Templates{}
|
|
|
|
data := struct {
|
|
Layout
|
|
BaseURL string
|
|
Registries []RegistryConfig
|
|
}{
|
|
Layout: Layout{
|
|
UIBaseURL: "http://localhost:8080",
|
|
CanonicalPath: "/ui/install",
|
|
},
|
|
BaseURL: "http://localhost:8080",
|
|
Registries: getRegistryConfigs("http://localhost:8080"),
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
if err := templates.Render(w, "install", data); err != nil {
|
|
t.Fatalf("Render failed: %v", err)
|
|
}
|
|
|
|
body := w.Body.String()
|
|
if strings.Contains(body, "package managers should be configured") {
|
|
t.Error("install banner should be hidden when UIBaseURL == BaseURL")
|
|
}
|
|
}
|
|
|
|
func TestTemplatesLazyLoading(t *testing.T) {
|
|
templates := &Templates{}
|
|
|
|
if templates.pages != nil {
|
|
t.Fatal("expected pages to be nil before first Render call")
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
_ = templates.Render(w, "dashboard", DashboardData{})
|
|
|
|
if templates.pages == nil {
|
|
t.Fatal("expected pages to be populated after first Render call")
|
|
}
|
|
}
|
|
|
|
func TestTemplatesRenderUnknownPage(t *testing.T) {
|
|
templates := &Templates{}
|
|
|
|
w := httptest.NewRecorder()
|
|
err := templates.Render(w, "nonexistent_page", nil)
|
|
if err == nil {
|
|
t.Error("expected error for unknown page")
|
|
}
|
|
}
|
|
|
|
func TestInstallPage(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.close()
|
|
|
|
req := httptest.NewRequest("GET", "/ui/install", 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()
|
|
|
|
// Should contain instructions for all registries
|
|
registries := []string{"npm", "Cargo", "RubyGems", "Go Modules", "PyPI", "Maven", "Gradle Build Cache", "NuGet", "Composer", "Conan", "Conda", "CRAN"}
|
|
for _, reg := range registries {
|
|
if !strings.Contains(body, reg) {
|
|
t.Errorf("install page should contain %s instructions", reg)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPackageShowPage(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.close()
|
|
|
|
// Create a package with license and version
|
|
pkg := &database.Package{
|
|
PURL: "pkg:npm/test-show",
|
|
Ecosystem: "npm",
|
|
Name: "test-show",
|
|
LatestVersion: sql.NullString{String: "2.0.0", Valid: true},
|
|
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/test-show@2.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/test-show", nil)
|
|
w := httptest.NewRecorder()
|
|
ts.handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, "test-show") {
|
|
t.Error("page should contain package name")
|
|
}
|
|
if !strings.Contains(body, "2.0.0") {
|
|
t.Error("page should contain version")
|
|
}
|
|
if !strings.Contains(body, "MIT") {
|
|
t.Error("page should contain license")
|
|
}
|
|
}
|
|
|
|
func TestPackageShowPage_NotFound(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.close()
|
|
|
|
req := httptest.NewRequest("GET", "/ui/package/npm/nonexistent", 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_NotFound(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.close()
|
|
|
|
req := httptest.NewRequest("GET", "/ui/package/npm/nonexistent/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 TestSearchPage_EmptyQuery(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.close()
|
|
|
|
req := httptest.NewRequest("GET", "/ui/search", nil)
|
|
w := httptest.NewRecorder()
|
|
ts.handler.ServeHTTP(w, req)
|
|
|
|
// Empty query should redirect to homepage
|
|
if w.Code != http.StatusSeeOther {
|
|
t.Errorf("expected redirect (303), got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestSearchPage_WithQuery(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.close()
|
|
|
|
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") {
|
|
t.Error("search page should contain the query")
|
|
}
|
|
}
|
|
|
|
func TestSearchPage_Pagination(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.close()
|
|
|
|
// Page 0 or negative should default to page 1
|
|
req := httptest.NewRequest("GET", "/ui/search?q=test&page=0", nil)
|
|
w := httptest.NewRecorder()
|
|
ts.handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d", w.Code)
|
|
}
|
|
|
|
// Non-numeric page should default to page 1
|
|
req = httptest.NewRequest("GET", "/ui/search?q=test&page=abc", nil)
|
|
w = httptest.NewRecorder()
|
|
ts.handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestSearchPage_EcosystemFilter(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.close()
|
|
|
|
req := httptest.NewRequest("GET", "/ui/search?q=test&ecosystem=npm", nil)
|
|
w := httptest.NewRecorder()
|
|
ts.handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestEcosystemBadgeLabel(t *testing.T) {
|
|
tests := []struct {
|
|
ecosystem string
|
|
want string
|
|
}{
|
|
{"oci", "container"},
|
|
{"deb", "debian"},
|
|
{"npm", "npm"},
|
|
{"cargo", "cargo"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := ecosystemBadgeLabel(tt.ecosystem)
|
|
if got != tt.want {
|
|
t.Errorf("ecosystemBadgeLabel(%q) = %q, want %q", tt.ecosystem, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEcosystemBadgeClasses(t *testing.T) {
|
|
// Every supported ecosystem should return a non-empty class string
|
|
ecosystems := supportedEcosystems()
|
|
for _, eco := range ecosystems {
|
|
classes := ecosystemBadgeClasses(eco)
|
|
if classes == "" {
|
|
t.Errorf("ecosystemBadgeClasses(%q) returned empty string", eco)
|
|
}
|
|
if !strings.Contains(classes, "inline-flex") {
|
|
t.Errorf("ecosystemBadgeClasses(%q) missing base classes", eco)
|
|
}
|
|
}
|
|
|
|
// Unknown ecosystem should still return classes
|
|
classes := ecosystemBadgeClasses("unknown")
|
|
if !strings.Contains(classes, "gray") {
|
|
t.Error("unknown ecosystem should use gray classes")
|
|
}
|
|
}
|
|
|
|
func TestFormatSize(t *testing.T) {
|
|
tests := []struct {
|
|
bytes int64
|
|
want string
|
|
}{
|
|
{0, "0 B"},
|
|
{100, "100 B"},
|
|
{1024, "1.0 KB"},
|
|
{1536, "1.5 KB"},
|
|
{1048576, "1.0 MB"},
|
|
{1073741824, "1.0 GB"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := formatSize(tt.bytes)
|
|
if got != tt.want {
|
|
t.Errorf("formatSize(%d) = %q, want %q", tt.bytes, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCategorizeLicense(t *testing.T) {
|
|
tests := []struct {
|
|
license sql.NullString
|
|
want string
|
|
}{
|
|
{sql.NullString{Valid: false}, "unknown"},
|
|
{sql.NullString{String: "", Valid: true}, "unknown"},
|
|
{sql.NullString{String: "MIT", Valid: true}, "permissive"},
|
|
{sql.NullString{String: "GPL-3.0", Valid: true}, "copyleft"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := categorizeLicense(tt.license)
|
|
if got != tt.want {
|
|
t.Errorf("categorizeLicense(%v) = %q, want %q", tt.license, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func BenchmarkTemplatesParse(b *testing.B) {
|
|
for b.Loop() {
|
|
t := &Templates{}
|
|
if err := t.load(); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func BenchmarkServerCreate(b *testing.B) {
|
|
for b.Loop() {
|
|
_ = &Server{
|
|
templates: &Templates{},
|
|
}
|
|
}
|
|
}
|
|
|
|
func BenchmarkFirstRender(b *testing.B) {
|
|
for b.Loop() {
|
|
t := &Templates{}
|
|
w := httptest.NewRecorder()
|
|
if err := t.Render(w, "dashboard", DashboardData{}); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
}
|