package server import ( "database/sql" "io/fs" "net/http" "net/http/httptest" "regexp" "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"}, }, }}, {"analytics", AnalyticsData{ Totals: AnalyticsTotals{ DownloadedBytes: 1_500_000_000, Downloaded: "1.4 GB", Downloads: "12,004", CacheSize: "420.0 MB", CachedArtifacts: "1,204", Packages: "310", Versions: "902", Ecosystems: 3, ActiveEcosystems: 2, Amplification: "3.4x", }, EnrichmentStats: EnrichmentStatsView{TotalVulnerabilities: 3, CriticalVulns: 1, HasVulns: true}, Ecosystems: []EcosystemRow{ {Ecosystem: "npm", DownloadedBytes: 1_000_000_000, Downloaded: "953.7 MB", Downloads: "9,000", CacheSize: "300.0 MB", AvgArtifactSize: "120 KB", Artifacts: "900", Packages: "200", Versions: "700", SharePct: "66.7"}, {Ecosystem: "cargo", DownloadedBytes: 500_000_000, Downloaded: "476.8 MB", Downloads: "3,004", CacheSize: "120.0 MB", AvgArtifactSize: "400 KB", Artifacts: "300", Packages: "100", Versions: "200", SharePct: "33.3"}, {Ecosystem: "rpm", Downloaded: "0 B", Downloads: "0", CacheSize: "0 B", AvgArtifactSize: "0 B", Artifacts: "0", Packages: "10", Versions: "2", SharePct: "0.0"}, }, Donut: donutView([]database.EcosystemStats{ {Ecosystem: "npm", CacheSize: 300, Downloads: 9000, DownloadedBytes: 1_000_000_000}, {Ecosystem: "cargo", CacheSize: 120, Downloads: 3004, DownloadedBytes: 500_000_000}, }, 1_500_000_000, "1.4 GB"), Runtime: RuntimeView{ Available: true, Requests: "12,004", ActiveRequests: "2", RequestMean: "8.1 ms", CacheHits: "9,000", CacheMisses: "1,000", CacheHitRatio: "90.0", HasCacheTraffic: true, StatusClasses: []LabelledCount{{Label: "2xx", Count: "11,900"}, {Label: "5xx", Count: "104", Bad: true}}, StorageOps: []LabelledStat{{Label: "get", Count: "9,000", Mean: "412 µs"}}, ScanErrors: []LabelledCount{{Label: "npm · clamav · timeout", Count: "3", Bad: true}}, ScanningOn: true, Breakers: []BreakerRow{{Registry: "registry.npmjs.org", State: "closed"}}, }, }}, {"analytics", AnalyticsData{}}, {"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, TotalPackages: 1, EcosystemFilters: []EcosystemFilter{{Ecosystem: "npm", 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, "") && !strings.Contains(body, "`, ``, ``, } for _, s := range want { if !strings.Contains(body, s) { t.Errorf("rendered body missing %q", s) } } } func TestFooterUsesBuildInfoWhenPageDefinesVersion(t *testing.T) { templates := &Templates{} buildInfo := BuildInfo{Version: "proxy-build-1.2.3", Commit: "abc123def"} wantFooter := "proxy proxy-build-1.2.3 (abc123def)" tests := []struct { name string page string data any shadow string }{ { name: "version show page", page: "version_show", data: VersionShowData{ Layout: Layout{BuildInfo: buildInfo}, Package: &database.Package{ PURL: "pkg:npm/lodash", Ecosystem: "npm", Name: "lodash", }, Version: &database.Version{ PURL: "pkg:npm/lodash@9.9.9", PackagePURL: "pkg:npm/lodash", }, }, shadow: "9.9.9", }, { name: "browse source page", page: "browse_source", data: BrowseSourceData{ Layout: Layout{BuildInfo: buildInfo}, Ecosystem: "npm", PackageName: "lodash", Version: "9.9.9", }, shadow: "9.9.9", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := httptest.NewRecorder() if err := templates.Render(w, tt.page, tt.data); err != nil { t.Fatalf("Render(%q) failed: %v", tt.page, err) } body := w.Body.String() if !strings.Contains(body, wantFooter) { t.Errorf("footer missing build info %q", wantFooter) } if strings.Contains(body, "proxy "+tt.shadow) { t.Errorf("footer used page Version %q instead of BuildInfo", tt.shadow) } }) } } 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 TestOCIRegistryInstructionsDockerPull(t *testing.T) { registries := getRegistryConfigs("http://package-proxy:8080") for _, registry := range registries { if registry.ID != "oci" { continue } want := "docker pull package-proxy:8080/library/nginx:latest" if !strings.Contains(string(registry.Instructions), want) { t.Errorf("OCI instructions = %q, want substring %q", registry.Instructions, want) } return } t.Fatal("OCI registry instructions not found") } func TestSwiftRegistryInstructionsAllowLocalHTTP(t *testing.T) { registries := getRegistryConfigs("http://localhost:8080") for _, registry := range registries { if registry.ID != "swift" { continue } if !strings.Contains(string(registry.Instructions), "--allow-insecure-http") { t.Error("Swift HTTP instructions do not allow the insecure local registry") } return } t.Fatal("Swift registry instructions not found") } 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 TestBuildEcosystemFilters(t *testing.T) { filters := buildEcosystemFilters(map[string]int64{ "npm": 3, "cargo": 2, "empty": 0, }) if len(filters) != 2 { t.Fatalf("expected 2 filters, got %d", len(filters)) } if filters[0].Ecosystem != "cargo" || filters[0].Count != 2 { t.Errorf("first filter = %#v, want cargo with count 2", filters[0]) } if filters[1].Ecosystem != "npm" || filters[1].Count != 3 { t.Errorf("second filter = %#v, want npm with count 3", filters[1]) } extra := buildEcosystemFilters(map[string]int64{"custom": 1}) if len(extra) != 1 || extra[0].Ecosystem != "custom" { t.Errorf("unexpected extra filters: %#v", extra) } } func TestEcosystemPillClasses(t *testing.T) { classes := ecosystemPillClasses("npm") if !strings.Contains(classes, "rounded-full") { t.Error("pill classes should use rounded-full") } if !strings.Contains(classes, "bg-red-100") { t.Error("npm pill should use npm colors") } } 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) } } } // thirdPartyAsset matches a script or stylesheet loaded from another origin. var thirdPartyAsset = regexp.MustCompile(`<(?:script[^>]*\ssrc|link[^>]*\shref)="(?:https?:)?//[^"]*"`) // Pages run on the proxy origin, so a script or stylesheet from elsewhere // would run with the proxy's privileges and break offline deployments. // Vendor it under static/vendor instead. func TestTemplatesLoadNoThirdPartyAssets(t *testing.T) { err := fs.WalkDir(templatesFS, "templates", func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } content, err := templatesFS.ReadFile(path) if err != nil { return err } for _, match := range thirdPartyAsset.FindAllString(string(content), -1) { t.Errorf("%s loads a third-party asset: %s", path, match) } return nil }) if err != nil { t.Fatal(err) } }