2026-10-25 12:36:36 +01:00
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/config"
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/handler"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
2026-10-25 14:25:35 +01:00
|
|
|
mirrorWheelPath = "/packages/ab/cd/example_pkg-1.0-py3-none-any.whl"
|
|
|
|
|
mirrorLinuxWheelPath = "/packages/23/45/example_pkg-1.0-cp312-cp312-manylinux_2_17_x86_64.whl"
|
|
|
|
|
mirrorSdistPath = "/packages/ef/01/example.pkg-1.0.tar.gz"
|
2026-10-25 12:36:36 +01:00
|
|
|
)
|
|
|
|
|
|
2026-10-25 14:25:35 +01:00
|
|
|
// pypiUpstream serves the JSON API for example-pkg 1.0 with two wheels, which
|
|
|
|
|
// are cached under the same name and version, and an sdist, whose filename
|
|
|
|
|
// spells the name differently. It counts file downloads by path.
|
2026-10-25 12:36:36 +01:00
|
|
|
type pypiUpstream struct {
|
|
|
|
|
*httptest.Server
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
files map[string]string
|
|
|
|
|
hits map[string]int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newPyPIUpstream(t *testing.T, digests map[string]string) *pypiUpstream {
|
|
|
|
|
t.Helper()
|
|
|
|
|
u := &pypiUpstream{
|
2026-10-25 14:25:35 +01:00
|
|
|
files: map[string]string{mirrorWheelPath: "wheel", mirrorLinuxWheelPath: "linux wheel", mirrorSdistPath: "sdist"},
|
2026-10-25 12:36:36 +01:00
|
|
|
hits: map[string]int{},
|
|
|
|
|
}
|
|
|
|
|
u.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.URL.Path == "/pypi/example-pkg/json" {
|
|
|
|
|
var files []map[string]any
|
2026-10-25 14:25:35 +01:00
|
|
|
for _, path := range []string{mirrorWheelPath, mirrorLinuxWheelPath, mirrorSdistPath} {
|
2026-10-25 12:36:36 +01:00
|
|
|
sum := sha256.Sum256([]byte(u.files[path]))
|
|
|
|
|
digest := hex.EncodeToString(sum[:])
|
|
|
|
|
if d, ok := digests[path]; ok {
|
|
|
|
|
digest = d
|
|
|
|
|
}
|
|
|
|
|
files = append(files, map[string]any{
|
|
|
|
|
"url": u.URL + path,
|
|
|
|
|
"digests": map[string]string{"sha256": digest},
|
|
|
|
|
"size": len(u.files[path]),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
|
|
|
"info": map[string]any{"name": "example-pkg"},
|
|
|
|
|
"releases": map[string]any{"1.0": files},
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
body, ok := u.files[r.URL.Path]
|
|
|
|
|
if !ok {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
u.mu.Lock()
|
|
|
|
|
u.hits[r.URL.Path]++
|
|
|
|
|
u.mu.Unlock()
|
|
|
|
|
_, _ = io.WriteString(w, body)
|
|
|
|
|
}))
|
|
|
|
|
t.Cleanup(u.Close)
|
|
|
|
|
return u
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (u *pypiUpstream) hitsFor(path string) int {
|
|
|
|
|
u.mu.Lock()
|
|
|
|
|
defer u.mu.Unlock()
|
|
|
|
|
return u.hits[path]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func pypiMirrorConfig(upstream *pypiUpstream) *config.Config {
|
|
|
|
|
cfg := config.Default()
|
|
|
|
|
cfg.Upstream.PyPI = upstream.URL
|
|
|
|
|
cfg.Upstream.PyPIDownload = upstream.URL
|
|
|
|
|
return cfg
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMirrorPyPIFetchesEveryFile(t *testing.T) {
|
|
|
|
|
upstream := newPyPIUpstream(t, nil)
|
|
|
|
|
cfg := pypiMirrorConfig(upstream)
|
|
|
|
|
|
|
|
|
|
progress, proxy := mirrorWithConfig(t, cfg, "pkg:pypi/example-pkg@1.0")
|
|
|
|
|
|
|
|
|
|
if progress.Completed != 1 || progress.Failed != 0 {
|
|
|
|
|
t.Fatalf("completed = %d, failed = %d, errors = %v", progress.Completed, progress.Failed, progress.Errors)
|
|
|
|
|
}
|
2026-10-25 14:25:35 +01:00
|
|
|
for path := range upstream.files {
|
2026-10-25 12:36:36 +01:00
|
|
|
if got := upstream.hitsFor(path); got != 1 {
|
|
|
|
|
t.Errorf("%s fetched %d times, want 1", path, got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-25 14:25:35 +01:00
|
|
|
// pip gets every file from the cache once the upstream is down.
|
2026-10-25 12:36:36 +01:00
|
|
|
upstream.Close()
|
|
|
|
|
pypi := handler.NewPyPIHandlerWithUpstreams(proxy, "http://proxy.test", cfg.Upstream.PyPI, cfg.Upstream.PyPIDownload)
|
2026-10-25 14:25:35 +01:00
|
|
|
for path, want := range upstream.files {
|
2026-10-25 12:36:36 +01:00
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
pypi.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
|
|
|
|
if rec.Code != http.StatusOK || rec.Body.String() != want {
|
|
|
|
|
t.Errorf("%s from cache: status = %d, body = %q, want 200 and %q", path, rec.Code, rec.Body, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMirrorPyPIFileNameQualifier(t *testing.T) {
|
|
|
|
|
upstream := newPyPIUpstream(t, nil)
|
|
|
|
|
|
|
|
|
|
progress, _ := mirrorWithConfig(t, pypiMirrorConfig(upstream), "pkg:pypi/example-pkg@1.0?file_name=example.pkg-1.0.tar.gz")
|
|
|
|
|
|
|
|
|
|
if progress.Completed != 1 || upstream.hitsFor(mirrorSdistPath) != 1 {
|
|
|
|
|
t.Errorf("completed = %d, sdist hits = %d, errors = %v; want the sdist", progress.Completed, upstream.hitsFor(mirrorSdistPath), progress.Errors)
|
|
|
|
|
}
|
|
|
|
|
if upstream.hitsFor(mirrorWheelPath) != 0 {
|
|
|
|
|
t.Error("the wheel was mirrored although file_name names the sdist")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
progress, _ = mirrorWithConfig(t, pypiMirrorConfig(upstream), "pkg:pypi/example-pkg@1.0?file_name=missing-1.0.tar.gz")
|
|
|
|
|
if progress.Failed != 1 || len(progress.Errors) != 1 || !strings.Contains(progress.Errors[0].Error, "missing-1.0.tar.gz") {
|
|
|
|
|
t.Errorf("failed = %d, errors = %v; want a failure naming the missing file", progress.Failed, progress.Errors)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMirrorPyPIRefusesDigestMismatch(t *testing.T) {
|
|
|
|
|
upstream := newPyPIUpstream(t, map[string]string{mirrorWheelPath: strings.Repeat("0", 64)})
|
|
|
|
|
|
|
|
|
|
progress, _ := mirrorWithConfig(t, pypiMirrorConfig(upstream), "pkg:pypi/example-pkg@1.0?file_name=example_pkg-1.0-py3-none-any.whl")
|
|
|
|
|
|
|
|
|
|
if progress.Failed != 1 || progress.Completed != 0 {
|
|
|
|
|
t.Errorf("completed = %d, failed = %d, errors = %v; want the mismatching wheel refused", progress.Completed, progress.Failed, progress.Errors)
|
|
|
|
|
}
|
|
|
|
|
}
|