mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-08-23 12:24:57 -04:00
* resolve name and version for PEP 658 metadata sidecars
* fix(pypi): parse Windows installer and egg filenames separately
The bdist_wininst and bdist_msi layout joins the platform to the version
with a '.' rather than a '-', so treating .exe/.msi like a wheel folded
the platform into the version: foo-1.0.win32-py2.0.exe resolved to
version "1.0.win32". Eggs shared the problem, as setuptools' hyphen
escaping is not universal: aws-sdk-1.0.0-py3.11.egg resolved to name
"aws", version "sdk".
Give each format its own parser. Wheels keep the PEP 427
spec-guaranteed field positions, eggs locate the version relative to the
py{X.Y} interpreter field, and Windows installers strip the platform and
interpreter fields before splitting name from version.
A PEP 658 sidecar resolves to the same name and version as the
distribution it describes, so it is cached under that version. Browse and
compare took the first cached artifact without checking its extension,
handing openArchive plain text: a version pip had only fetched metadata
for reported hasCached and then 500'd.
Add firstBrowsableArtifact, replacing five duplicated selection loops,
and export PyPIMetadataSuffix so the suffix has a single definition.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
898 lines
26 KiB
Go
898 lines
26 KiB
Go
package server
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/git-pkgs/proxy/internal/database"
|
|
)
|
|
|
|
const testArchiveName = "test.tar.gz"
|
|
|
|
func TestHandleBrowseList(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.close()
|
|
|
|
// Create a test tar.gz archive
|
|
archiveData := createTestArchive(t)
|
|
artifactsDir := filepath.Join(ts.tempDir, "artifacts")
|
|
if err := os.MkdirAll(artifactsDir, 0755); err != nil {
|
|
t.Fatalf("failed to create artifacts dir: %v", err)
|
|
}
|
|
storagePath := filepath.Join(artifactsDir, testArchiveName)
|
|
if err := os.WriteFile(storagePath, archiveData, 0644); err != nil {
|
|
t.Fatalf("failed to write test archive: %v", err)
|
|
}
|
|
// Storage path relative to artifacts directory
|
|
relPath := testArchiveName
|
|
|
|
// Setup test package and artifact
|
|
pkg := &database.Package{
|
|
PURL: "pkg:npm/test-browse",
|
|
Ecosystem: "npm",
|
|
Name: "test-browse",
|
|
}
|
|
if err := ts.db.UpsertPackage(pkg); err != nil {
|
|
t.Fatalf("failed to upsert package: %v", err)
|
|
}
|
|
|
|
ver := &database.Version{
|
|
PURL: "pkg:npm/test-browse@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-browse-1.0.0.tgz",
|
|
UpstreamURL: "https://registry.npmjs.org/test-browse/-/test-browse-1.0.0.tgz",
|
|
StoragePath: sql.NullString{String: relPath, Valid: true},
|
|
}
|
|
if err := ts.db.UpsertArtifact(artifact); err != nil {
|
|
t.Fatalf("failed to upsert artifact: %v", err)
|
|
}
|
|
|
|
// Test listing root directory
|
|
req := httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0", 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())
|
|
}
|
|
|
|
var response BrowseListResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if len(response.Files) == 0 {
|
|
t.Error("expected files in response")
|
|
}
|
|
|
|
// Test listing subdirectory
|
|
req = httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0?path=lib", nil)
|
|
w = httptest.NewRecorder()
|
|
ts.handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleBrowseFile(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.close()
|
|
|
|
// Create a test tar.gz archive
|
|
archiveData := createTestArchive(t)
|
|
artifactsDir := filepath.Join(ts.tempDir, "artifacts")
|
|
if err := os.MkdirAll(artifactsDir, 0755); err != nil {
|
|
t.Fatalf("failed to create artifacts dir: %v", err)
|
|
}
|
|
storagePath := filepath.Join(artifactsDir, testArchiveName)
|
|
if err := os.WriteFile(storagePath, archiveData, 0644); err != nil {
|
|
t.Fatalf("failed to write test archive: %v", err)
|
|
}
|
|
// Storage path relative to artifacts directory
|
|
relPath := testArchiveName
|
|
|
|
// Setup test package and artifact
|
|
pkg := &database.Package{
|
|
PURL: "pkg:npm/test-browse",
|
|
Ecosystem: "npm",
|
|
Name: "test-browse",
|
|
}
|
|
if err := ts.db.UpsertPackage(pkg); err != nil {
|
|
t.Fatalf("failed to upsert package: %v", err)
|
|
}
|
|
|
|
ver := &database.Version{
|
|
PURL: "pkg:npm/test-browse@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-browse-1.0.0.tgz",
|
|
UpstreamURL: "https://registry.npmjs.org/test-browse/-/test-browse-1.0.0.tgz",
|
|
StoragePath: sql.NullString{String: relPath, Valid: true},
|
|
}
|
|
if err := ts.db.UpsertArtifact(artifact); err != nil {
|
|
t.Fatalf("failed to upsert artifact: %v", err)
|
|
}
|
|
|
|
files := []struct {
|
|
path string
|
|
content string
|
|
contentType string
|
|
}{
|
|
{"README.md", "# Test Package\n", contentTypePlainText},
|
|
{"notes.data", "short text\n", contentTypePlainText},
|
|
{"logo", "\x89PNG\r\n\x1a\nimage data", "image/png"},
|
|
{"page", "<!DOCTYPE html><html></html>", contentTypePlainText},
|
|
{"misleading.txt", "\x89PNG\r\n\x1a\nimage data", contentTypePlainText},
|
|
}
|
|
for _, file := range files {
|
|
t.Run(file.path, func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0/file/"+file.path, 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())
|
|
}
|
|
if w.Body.String() != file.content {
|
|
t.Errorf("unexpected file content: %q", w.Body.String())
|
|
}
|
|
if got := w.Header().Get("Content-Type"); got != file.contentType {
|
|
t.Errorf("Content-Type = %q, want %q", got, file.contentType)
|
|
}
|
|
if got := w.Header().Get("Content-Security-Policy"); got != "sandbox" {
|
|
t.Errorf("Content-Security-Policy = %q, want sandbox", got)
|
|
}
|
|
if got := w.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Test fetching non-existent file
|
|
req := httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0/file/nonexistent.txt", nil)
|
|
w := httptest.NewRecorder()
|
|
ts.handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected status 404 for non-existent file, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestBrowseContentTypePolicy(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
filename string
|
|
prefix []byte
|
|
expectedCT string
|
|
}{
|
|
{"text extension", "file.txt", nil, contentTypePlainText},
|
|
{"markdown extension", "file.md", nil, contentTypePlainText},
|
|
{"JSON extension", "file.json", nil, "application/json; charset=utf-8"},
|
|
{"JavaScript extension", "file.js", nil, "application/javascript; charset=utf-8"},
|
|
{"Go extension", "file.go", nil, "text/x-go; charset=utf-8"},
|
|
{"Python extension", "file.py", nil, "text/x-python; charset=utf-8"},
|
|
{"Rust extension", "file.rs", nil, "text/x-rust; charset=utf-8"},
|
|
{"HTML extension", "file.html", nil, contentTypePlainText},
|
|
{"HTM extension", "file.htm", nil, contentTypePlainText},
|
|
{"XHTML extension", "file.xhtml", nil, contentTypePlainText},
|
|
{"SVG extension", "file.svg", nil, contentTypePlainText},
|
|
{"PNG extension", "file.png", nil, "image/png"},
|
|
{"JPEG extension", "file.jpg", nil, "image/jpeg"},
|
|
{"README", "README", nil, contentTypePlainText},
|
|
{"LICENSE", "LICENSE", nil, contentTypePlainText},
|
|
{"Makefile", "Makefile", nil, contentTypePlainText},
|
|
{"gitignore", ".gitignore", nil, contentTypePlainText},
|
|
{"unknown empty", "file.bin", nil, "application/octet-stream"},
|
|
{"extensionless PNG", "asset", []byte("\x89PNG\r\n\x1a\n"), "image/png"},
|
|
{"extensionless JPEG", "asset", []byte("\xff\xd8\xff"), "image/jpeg"},
|
|
{"extensionless GIF", "asset", []byte("GIF89a"), "image/gif"},
|
|
{"extensionless PDF", "asset", []byte("%PDF-1.7"), "application/pdf"},
|
|
{"extensionless text", "asset", []byte("plain text\n"), contentTypePlainText},
|
|
{"extensionless HTML", "asset", []byte("<!DOCTYPE html><html></html>"), contentTypePlainText},
|
|
{"extensionless XML", "asset", []byte("<?xml version=\"1.0\"?><root/>"), contentTypePlainText},
|
|
{"extensionless SVG", "asset", []byte("<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>"), contentTypePlainText},
|
|
{"extensionless ZIP", "asset", []byte("PK\x03\x04"), "application/octet-stream"},
|
|
{"extensionless binary", "asset", []byte{0, 1, 2}, "application/octet-stream"},
|
|
{"known path wins", "file.txt", []byte("\x89PNG\r\n\x1a\n"), contentTypePlainText},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, knownPath := detectContentTypeFromPath(tt.filename)
|
|
if !knownPath {
|
|
got = detectContentTypeFromPrefix(tt.prefix)
|
|
}
|
|
if got != tt.expectedCT {
|
|
t.Errorf("content type for %q with prefix %q = %q, want %q", tt.filename, tt.prefix, got, tt.expectedCT)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOpenArchiveSizeLimit(t *testing.T) {
|
|
huge := bytes.Repeat([]byte("x"), int(maxBrowseArchiveSize)+1)
|
|
for _, eco := range []string{"npm", "go"} {
|
|
_, err := openArchive("test.tar.gz", bytes.NewReader(huge), eco)
|
|
if err == nil {
|
|
t.Fatalf("%s: expected error for oversized archive, got nil", eco)
|
|
}
|
|
if !strings.Contains(err.Error(), "too large") {
|
|
t.Fatalf("%s: expected 'too large' error, got: %v", eco, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsLikelyText(t *testing.T) {
|
|
tests := []struct {
|
|
filename string
|
|
expected bool
|
|
}{
|
|
{"README", true},
|
|
{"README.md", true},
|
|
{"LICENSE", true},
|
|
{"Makefile", true},
|
|
{"Dockerfile", true},
|
|
{".gitignore", true},
|
|
{"file.bin", false},
|
|
{"data.dat", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.filename, func(t *testing.T) {
|
|
got := isLikelyText(tt.filename)
|
|
if got != tt.expected {
|
|
t.Errorf("isLikelyText(%q) = %v, want %v", tt.filename, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// createTestArchive creates a tar.gz archive in memory with test files
|
|
// in npm format (with package/ prefix)
|
|
func createTestArchive(t *testing.T) []byte {
|
|
t.Helper()
|
|
|
|
buf := new(bytes.Buffer)
|
|
gw := gzip.NewWriter(buf)
|
|
tw := tar.NewWriter(gw)
|
|
|
|
files := map[string]string{
|
|
"package/README.md": "# Test Package\n",
|
|
"package/package.json": `{"name": "test-browse"}`,
|
|
"package/lib/index.js": "module.exports = {};",
|
|
"package/lib/helper.js": "module.exports.help = () => {};",
|
|
"package/test/index.test.js": "// tests",
|
|
"package/notes.data": "short text\n",
|
|
"package/logo": "\x89PNG\r\n\x1a\nimage data",
|
|
"package/page": "<!DOCTYPE html><html></html>",
|
|
"package/misleading.txt": "\x89PNG\r\n\x1a\nimage data",
|
|
}
|
|
|
|
for path, content := range files {
|
|
header := &tar.Header{
|
|
Name: path,
|
|
Size: int64(len(content)),
|
|
Mode: 0644,
|
|
}
|
|
if err := tw.WriteHeader(header); err != nil {
|
|
t.Fatalf("failed to write tar header: %v", err)
|
|
}
|
|
if _, err := tw.Write([]byte(content)); err != nil {
|
|
t.Fatalf("failed to write tar content: %v", err)
|
|
}
|
|
}
|
|
|
|
if err := tw.Close(); err != nil {
|
|
t.Fatalf("failed to close tar writer: %v", err)
|
|
}
|
|
if err := gw.Close(); err != nil {
|
|
t.Fatalf("failed to close gzip writer: %v", err)
|
|
}
|
|
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func TestBrowseNonCachedArtifact(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.close()
|
|
|
|
// Setup test package without cached artifact
|
|
pkg := &database.Package{
|
|
PURL: "pkg:npm/not-cached",
|
|
Ecosystem: "npm",
|
|
Name: "not-cached",
|
|
}
|
|
if err := ts.db.UpsertPackage(pkg); err != nil {
|
|
t.Fatalf("failed to upsert package: %v", err)
|
|
}
|
|
|
|
ver := &database.Version{
|
|
PURL: "pkg:npm/not-cached@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: "not-cached-1.0.0.tgz",
|
|
UpstreamURL: "https://registry.npmjs.org/not-cached/-/not-cached-1.0.0.tgz",
|
|
// No StoragePath - not cached
|
|
}
|
|
if err := ts.db.UpsertArtifact(artifact); err != nil {
|
|
t.Fatalf("failed to upsert artifact: %v", err)
|
|
}
|
|
|
|
// Try to browse
|
|
req := httptest.NewRequest("GET", "/ui/api/browse/npm/not-cached/1.0.0", nil)
|
|
w := httptest.NewRecorder()
|
|
ts.handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected status 404 for non-cached artifact, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleBrowseSourcePage(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.close()
|
|
|
|
// Create a test tar.gz archive
|
|
archiveData := createTestArchive(t)
|
|
artifactsDir := filepath.Join(ts.tempDir, "artifacts")
|
|
if err := os.MkdirAll(artifactsDir, 0755); err != nil {
|
|
t.Fatalf("failed to create artifacts dir: %v", err)
|
|
}
|
|
storagePath := filepath.Join(artifactsDir, testArchiveName)
|
|
if err := os.WriteFile(storagePath, archiveData, 0644); err != nil {
|
|
t.Fatalf("failed to write test archive: %v", err)
|
|
}
|
|
relPath := testArchiveName
|
|
|
|
// Setup test package and artifact
|
|
pkg := &database.Package{
|
|
PURL: "pkg:npm/test-browse",
|
|
Ecosystem: "npm",
|
|
Name: "test-browse",
|
|
}
|
|
if err := ts.db.UpsertPackage(pkg); err != nil {
|
|
t.Fatalf("failed to upsert package: %v", err)
|
|
}
|
|
|
|
ver := &database.Version{
|
|
PURL: "pkg:npm/test-browse@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-browse-1.0.0.tgz",
|
|
UpstreamURL: "https://registry.npmjs.org/test-browse/-/test-browse-1.0.0.tgz",
|
|
StoragePath: sql.NullString{String: relPath, Valid: true},
|
|
}
|
|
if err := ts.db.UpsertArtifact(artifact); err != nil {
|
|
t.Fatalf("failed to upsert artifact: %v", err)
|
|
}
|
|
|
|
// Test the browse source page loads
|
|
req := httptest.NewRequest("GET", "/ui/package/npm/test-browse/1.0.0/browse", 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()
|
|
|
|
// Check that the page contains expected elements
|
|
expectedStrings := []string{
|
|
"Browse Source",
|
|
"test-browse",
|
|
"1.0.0",
|
|
"file-tree",
|
|
"file-content",
|
|
"loadFileTree",
|
|
"loadFile",
|
|
}
|
|
|
|
for _, expected := range expectedStrings {
|
|
if !strings.Contains(body, expected) {
|
|
t.Errorf("browse source page missing expected content: %q", expected)
|
|
}
|
|
}
|
|
|
|
// Check that the escapeHTML function is present for XSS protection
|
|
if !strings.Contains(body, "function escapeHTML(str)") {
|
|
t.Error("browse source page missing escapeHTML function for XSS protection")
|
|
}
|
|
|
|
// Check that onclick handlers use escapeHTML
|
|
if strings.Contains(body, "onclick=\"loadFileTree('${file.path}')") {
|
|
t.Error("browse source page has unescaped file.path in onclick handler")
|
|
}
|
|
if strings.Contains(body, "onclick=\"loadFile('${file.path}')") {
|
|
t.Error("browse source page has unescaped file.path in onclick handler")
|
|
}
|
|
|
|
// Check that ecosystem, package name, and version are set in JavaScript
|
|
if !strings.Contains(body, "const ecosystem = 'npm'") {
|
|
t.Error("browse source page missing ecosystem variable")
|
|
}
|
|
if !strings.Contains(body, "const packageName = 'test-browse'") {
|
|
t.Error("browse source page missing packageName variable")
|
|
}
|
|
if !strings.Contains(body, "const version = '1.0.0'") {
|
|
t.Error("browse source page missing version variable")
|
|
}
|
|
|
|
// Verify content type
|
|
contentType := w.Header().Get("Content-Type")
|
|
if !strings.Contains(contentType, "text/html") {
|
|
t.Errorf("expected HTML content type, got %q", contentType)
|
|
}
|
|
}
|
|
|
|
func TestHandleCompareDiff(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.close()
|
|
|
|
// Create two test archives with different content
|
|
archive1Data := createArchiveWithContent(t, map[string]string{
|
|
"README.md": "# Version 1\n",
|
|
"main.go": "package main\n",
|
|
})
|
|
archive2Data := createArchiveWithContent(t, map[string]string{
|
|
"README.md": "# Version 2\n",
|
|
"main.go": "package main\n\nfunc main() {}\n",
|
|
"new.txt": "new file\n",
|
|
})
|
|
|
|
artifactsDir := filepath.Join(ts.tempDir, "artifacts")
|
|
if err := os.MkdirAll(artifactsDir, 0755); err != nil {
|
|
t.Fatalf("failed to create artifacts dir: %v", err)
|
|
}
|
|
|
|
// Write archives
|
|
if err := os.WriteFile(filepath.Join(artifactsDir, "v1.tar.gz"), archive1Data, 0644); err != nil {
|
|
t.Fatalf("failed to write v1 archive: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(artifactsDir, "v2.tar.gz"), archive2Data, 0644); err != nil {
|
|
t.Fatalf("failed to write v2 archive: %v", err)
|
|
}
|
|
|
|
// Setup package and versions
|
|
pkg := &database.Package{
|
|
PURL: "pkg:npm/test-compare",
|
|
Ecosystem: "npm",
|
|
Name: "test-compare",
|
|
}
|
|
if err := ts.db.UpsertPackage(pkg); err != nil {
|
|
t.Fatalf("failed to upsert package: %v", err)
|
|
}
|
|
|
|
ver1 := &database.Version{
|
|
PURL: "pkg:npm/test-compare@1.0.0",
|
|
PackagePURL: pkg.PURL,
|
|
}
|
|
if err := ts.db.UpsertVersion(ver1); err != nil {
|
|
t.Fatalf("failed to upsert version: %v", err)
|
|
}
|
|
|
|
ver2 := &database.Version{
|
|
PURL: "pkg:npm/test-compare@2.0.0",
|
|
PackagePURL: pkg.PURL,
|
|
}
|
|
if err := ts.db.UpsertVersion(ver2); err != nil {
|
|
t.Fatalf("failed to upsert version: %v", err)
|
|
}
|
|
|
|
artifact1 := &database.Artifact{
|
|
VersionPURL: ver1.PURL,
|
|
Filename: "test-compare-1.0.0.tgz",
|
|
UpstreamURL: "https://registry.npmjs.org/test-compare/-/test-compare-1.0.0.tgz",
|
|
StoragePath: sql.NullString{String: "v1.tar.gz", Valid: true},
|
|
}
|
|
if err := ts.db.UpsertArtifact(artifact1); err != nil {
|
|
t.Fatalf("failed to upsert artifact: %v", err)
|
|
}
|
|
|
|
artifact2 := &database.Artifact{
|
|
VersionPURL: ver2.PURL,
|
|
Filename: "test-compare-2.0.0.tgz",
|
|
UpstreamURL: "https://registry.npmjs.org/test-compare/-/test-compare-2.0.0.tgz",
|
|
StoragePath: sql.NullString{String: "v2.tar.gz", Valid: true},
|
|
}
|
|
if err := ts.db.UpsertArtifact(artifact2); err != nil {
|
|
t.Fatalf("failed to upsert artifact: %v", err)
|
|
}
|
|
|
|
// Test the compare endpoint
|
|
req := httptest.NewRequest("GET", "/ui/api/compare/npm/test-compare/1.0.0/2.0.0", 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())
|
|
}
|
|
|
|
// Parse response
|
|
var result map[string]interface{}
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
// Check that we have files
|
|
files, ok := result["files"].([]interface{})
|
|
if !ok {
|
|
t.Fatal("response should have files array")
|
|
}
|
|
|
|
if len(files) == 0 {
|
|
t.Error("should have detected file changes")
|
|
}
|
|
|
|
// Check counts exist
|
|
if _, ok := result["files_changed"]; !ok {
|
|
t.Error("response should have files_changed")
|
|
}
|
|
if _, ok := result["files_added"]; !ok {
|
|
t.Error("response should have files_added")
|
|
}
|
|
}
|
|
|
|
func createArchiveWithContent(t *testing.T, files map[string]string) []byte {
|
|
t.Helper()
|
|
|
|
buf := new(bytes.Buffer)
|
|
gw := gzip.NewWriter(buf)
|
|
tw := tar.NewWriter(gw)
|
|
|
|
// Add package/ prefix for npm-style archives
|
|
for path, content := range files {
|
|
prefixedPath := "package/" + path
|
|
header := &tar.Header{
|
|
Name: prefixedPath,
|
|
Size: int64(len(content)),
|
|
Mode: 0644,
|
|
}
|
|
if err := tw.WriteHeader(header); err != nil {
|
|
t.Fatalf("failed to write tar header: %v", err)
|
|
}
|
|
if _, err := tw.Write([]byte(content)); err != nil {
|
|
t.Fatalf("failed to write tar content: %v", err)
|
|
}
|
|
}
|
|
|
|
if err := tw.Close(); err != nil {
|
|
t.Fatalf("failed to close tar writer: %v", err)
|
|
}
|
|
if err := gw.Close(); err != nil {
|
|
t.Fatalf("failed to close gzip writer: %v", err)
|
|
}
|
|
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func TestHandleComparePage(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.close()
|
|
|
|
// Test valid format with ... separator
|
|
req := httptest.NewRequest("GET", "/ui/package/npm/test/compare/1.0.0...2.0.0", 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()
|
|
|
|
// Check that versions are set correctly in JavaScript
|
|
if !strings.Contains(body, "const fromVersion = '1.0.0'") {
|
|
t.Error("page should set fromVersion")
|
|
}
|
|
if !strings.Contains(body, "const toVersion = '2.0.0'") {
|
|
t.Error("page should set toVersion")
|
|
}
|
|
|
|
// Test invalid format (missing separator)
|
|
req = httptest.NewRequest("GET", "/ui/package/npm/test/compare/invalid", nil)
|
|
w = httptest.NewRecorder()
|
|
ts.handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400 for invalid format, got %d", w.Code)
|
|
}
|
|
|
|
// Test with only one dot (should fail)
|
|
req = httptest.NewRequest("GET", "/ui/package/npm/test/compare/1.0.0.2.0.0", nil)
|
|
w = httptest.NewRecorder()
|
|
ts.handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400 for invalid separator, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestOpenArchiveDetectsExtensionlessTarGz(t *testing.T) {
|
|
reader, err := openArchive("artifact", bytes.NewReader(createTestArchive(t)), "npm")
|
|
if err != nil {
|
|
t.Fatalf("openArchive failed: %v", err)
|
|
}
|
|
defer func() { _ = reader.Close() }()
|
|
|
|
files, err := reader.List()
|
|
if err != nil {
|
|
t.Fatalf("List failed: %v", err)
|
|
}
|
|
if len(files) == 0 {
|
|
t.Fatal("expected files in extensionless archive")
|
|
}
|
|
}
|
|
|
|
func TestOpenArchiveStripsSingleRootDir(t *testing.T) {
|
|
data := createZipArchive(t, map[string]string{
|
|
"repo-abc123/README.md": "hello",
|
|
"repo-abc123/src/main.go": "package main",
|
|
"repo-abc123/go.mod": "module test",
|
|
})
|
|
reader, err := openArchive("test.zip", bytes.NewReader(data), "composer")
|
|
if err != nil {
|
|
t.Fatalf("openArchive failed: %v", err)
|
|
}
|
|
defer func() { _ = reader.Close() }()
|
|
|
|
files, err := reader.List()
|
|
if err != nil {
|
|
t.Fatalf("List failed: %v", err)
|
|
}
|
|
for _, f := range files {
|
|
if strings.HasPrefix(f.Path, "repo-abc123/") {
|
|
t.Errorf("file %q still has root prefix after stripping", f.Path)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpenArchiveMultipleRootDirs(t *testing.T) {
|
|
data := createZipArchive(t, map[string]string{
|
|
"src/main.go": "package main",
|
|
"docs/README.md": "hello",
|
|
})
|
|
reader, err := openArchive("test.zip", bytes.NewReader(data), "composer")
|
|
if err != nil {
|
|
t.Fatalf("openArchive failed: %v", err)
|
|
}
|
|
defer func() { _ = reader.Close() }()
|
|
|
|
files, err := reader.List()
|
|
if err != nil {
|
|
t.Fatalf("List failed: %v", err)
|
|
}
|
|
paths := make(map[string]bool)
|
|
for _, f := range files {
|
|
paths[f.Path] = true
|
|
}
|
|
if !paths["src/main.go"] {
|
|
t.Error("expected src/main.go to remain unchanged")
|
|
}
|
|
if !paths["docs/README.md"] {
|
|
t.Error("expected docs/README.md to remain unchanged")
|
|
}
|
|
}
|
|
|
|
func TestOpenArchiveFlatNoSubdirs(t *testing.T) {
|
|
data := createZipArchive(t, map[string]string{
|
|
"README.md": "hello",
|
|
"main.go": "package main",
|
|
})
|
|
reader, err := openArchive("test.zip", bytes.NewReader(data), "composer")
|
|
if err != nil {
|
|
t.Fatalf("openArchive failed: %v", err)
|
|
}
|
|
defer func() { _ = reader.Close() }()
|
|
|
|
files, err := reader.List()
|
|
if err != nil {
|
|
t.Fatalf("List failed: %v", err)
|
|
}
|
|
paths := make(map[string]bool)
|
|
for _, f := range files {
|
|
paths[f.Path] = true
|
|
}
|
|
if !paths["README.md"] {
|
|
t.Error("expected README.md at root")
|
|
}
|
|
}
|
|
|
|
func TestOpenArchiveNpmUsesPackagePrefix(t *testing.T) {
|
|
data := createTarGzArchive(t, map[string]string{
|
|
"package/README.md": "hello",
|
|
"package/index.js": "module.exports = {}",
|
|
})
|
|
reader, err := openArchive("pkg.tgz", bytes.NewReader(data), "npm")
|
|
if err != nil {
|
|
t.Fatalf("openArchive failed: %v", err)
|
|
}
|
|
defer func() { _ = reader.Close() }()
|
|
|
|
files, err := reader.List()
|
|
if err != nil {
|
|
t.Fatalf("List failed: %v", err)
|
|
}
|
|
for _, f := range files {
|
|
if strings.HasPrefix(f.Path, "package/") {
|
|
t.Errorf("file %q still has package/ prefix", f.Path)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpenArchiveExtensionlessFilename(t *testing.T) {
|
|
data := createZipArchive(t, map[string]string{
|
|
"repo-hash/README.md": "hello",
|
|
})
|
|
reader, err := openArchive("d2e2f014ccd6ec9fae8dbe6336a4164346a2a856", bytes.NewReader(data), "composer")
|
|
if err != nil {
|
|
t.Fatalf("openArchive failed: %v", err)
|
|
}
|
|
defer func() { _ = reader.Close() }()
|
|
|
|
files, err := reader.List()
|
|
if err != nil {
|
|
t.Fatalf("List failed: %v", err)
|
|
}
|
|
if len(files) == 0 {
|
|
t.Fatal("expected files in archive")
|
|
}
|
|
for _, f := range files {
|
|
if strings.HasPrefix(f.Path, "repo-hash/") {
|
|
t.Errorf("file %q still has root prefix", f.Path)
|
|
}
|
|
}
|
|
}
|
|
|
|
func createZipArchive(t *testing.T, files map[string]string) []byte {
|
|
t.Helper()
|
|
buf := new(bytes.Buffer)
|
|
w := zip.NewWriter(buf)
|
|
|
|
for name, content := range files {
|
|
f, err := w.Create(name)
|
|
if err != nil {
|
|
t.Fatalf("failed to create zip entry: %v", err)
|
|
}
|
|
if _, err := f.Write([]byte(content)); err != nil {
|
|
t.Fatalf("failed to write zip content: %v", err)
|
|
}
|
|
}
|
|
|
|
if err := w.Close(); err != nil {
|
|
t.Fatalf("failed to close zip writer: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func createTarGzArchive(t *testing.T, files map[string]string) []byte {
|
|
t.Helper()
|
|
buf := new(bytes.Buffer)
|
|
gw := gzip.NewWriter(buf)
|
|
tw := tar.NewWriter(gw)
|
|
|
|
for name, content := range files {
|
|
header := &tar.Header{
|
|
Name: name,
|
|
Size: int64(len(content)),
|
|
Mode: 0644,
|
|
}
|
|
if err := tw.WriteHeader(header); err != nil {
|
|
t.Fatalf("failed to write tar header: %v", err)
|
|
}
|
|
if _, err := tw.Write([]byte(content)); err != nil {
|
|
t.Fatalf("failed to write tar content: %v", err)
|
|
}
|
|
}
|
|
|
|
if err := tw.Close(); err != nil {
|
|
t.Fatalf("failed to close tar writer: %v", err)
|
|
}
|
|
if err := gw.Close(); err != nil {
|
|
t.Fatalf("failed to close gzip writer: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// TestFirstBrowsableArtifact guards artifact selection against PEP 658
|
|
// core-metadata sidecars. A sidecar resolves to the same version as the
|
|
// distribution it describes, so it is cached under that version, but it is plain
|
|
// text and openArchive cannot parse it.
|
|
func TestFirstBrowsableArtifact(t *testing.T) {
|
|
cached := func(filename string) database.Artifact {
|
|
return database.Artifact{
|
|
Filename: filename,
|
|
StoragePath: sql.NullString{String: "pypi/" + filename, Valid: true},
|
|
}
|
|
}
|
|
uncached := func(filename string) database.Artifact {
|
|
return database.Artifact{Filename: filename}
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
artifacts []database.Artifact
|
|
want string
|
|
}{
|
|
{"no artifacts", nil, ""},
|
|
{
|
|
"sidecar only is not browsable",
|
|
[]database.Artifact{cached("foo-1.0-py3-none-any.whl.metadata")},
|
|
"",
|
|
},
|
|
{
|
|
// '-' (0x2D) sorts before '.' (0x2E), so the sidecar precedes the
|
|
// sdist in the filename-ordered list the query returns.
|
|
"sidecar sorting ahead of the sdist is skipped",
|
|
[]database.Artifact{cached("foo-1.0-py3-none-any.whl.metadata"), cached("foo-1.0.tar.gz")},
|
|
"foo-1.0.tar.gz",
|
|
},
|
|
{
|
|
"sidecar skipped in favour of its own wheel",
|
|
[]database.Artifact{cached("foo-1.0-py3-none-any.whl.metadata"), cached("foo-1.0-py3-none-any.whl")},
|
|
"foo-1.0-py3-none-any.whl",
|
|
},
|
|
{
|
|
"uncached archive is still not selected",
|
|
[]database.Artifact{cached("foo-1.0.tar.gz.metadata"), uncached("foo-1.0.tar.gz")},
|
|
"",
|
|
},
|
|
{"plain sdist", []database.Artifact{cached("foo-1.0.tar.gz")}, "foo-1.0.tar.gz"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := firstBrowsableArtifact(tt.artifacts)
|
|
|
|
if tt.want == "" {
|
|
if got != nil {
|
|
t.Fatalf("firstBrowsableArtifact() = %q, want nil", got.Filename)
|
|
}
|
|
return
|
|
}
|
|
|
|
if got == nil {
|
|
t.Fatalf("firstBrowsableArtifact() = nil, want %q", tt.want)
|
|
}
|
|
if got.Filename != tt.want {
|
|
t.Errorf("firstBrowsableArtifact() = %q, want %q", got.Filename, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|