mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 15:49:16 -05:00
253 lines
8.5 KiB
Go
253 lines
8.5 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/git-pkgs/proxy/internal/config"
|
|
)
|
|
|
|
func TestCooldownProfilesThroughServerStart(t *testing.T) {
|
|
if os.Getenv("PROXY_TEST_PROFILES") != "1" {
|
|
cmd := exec.Command(os.Args[0], "-test.run=^TestCooldownProfilesThroughServerStart$")
|
|
cmd.Env = append(os.Environ(), "PROXY_TEST_PROFILES=1")
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("profile server: %v\n%s", err, output)
|
|
}
|
|
return
|
|
}
|
|
upstream, counts := profileTestUpstream(t)
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = listener.Close() })
|
|
cfg := config.Default()
|
|
cfg.BaseURL = "http://" + listener.Addr().String()
|
|
cfg.Database.Path = filepath.Join(t.TempDir(), "cache.db")
|
|
cfg.Storage.URL = "file://" + t.TempDir()
|
|
cfg.CacheMetadata = true
|
|
cfg.MetadataTTL = "1h"
|
|
cfg.Upstream.AllowLoopback = true
|
|
cfg.Upstream.NPM = upstream.URL + "/npm"
|
|
cfg.Upstream.Cargo = upstream.URL + "/cargo"
|
|
cfg.Upstream.CargoDownload = upstream.URL + "/crates"
|
|
cfg.Upstream.Gem = upstream.URL + "/gem"
|
|
cfg.Upstream.Go = upstream.URL + "/go"
|
|
cfg.Cooldown = config.CooldownConfig{Profiles: map[string]config.CooldownConfig{
|
|
"3d": {Default: "3d"}, "7d": {Default: "7d"},
|
|
}}
|
|
if err := cfg.Validate(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)), BuildInfo{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
done := make(chan error, 1)
|
|
go func() { done <- s.Start(listener) }()
|
|
client := &http.Client{Timeout: 5 * time.Second}
|
|
defer func() {
|
|
client.CloseIdleConnections()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := s.Shutdown(ctx); err != nil {
|
|
t.Error(err)
|
|
}
|
|
if err := <-done; !errors.Is(err, http.ErrServerClosed) {
|
|
t.Error(err)
|
|
}
|
|
}()
|
|
get := func(path string, headers map[string]string) (*http.Response, string) {
|
|
t.Helper()
|
|
req, err := http.NewRequest(http.MethodGet, cfg.BaseURL+path, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for name, value := range headers {
|
|
req.Header.Set(name, value)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return resp, string(body)
|
|
}
|
|
assertProfileVersionLists(t, get)
|
|
assertProfileRouting(t, cfg.BaseURL, get, counts)
|
|
assertConcurrentProfileRequests(t, client, cfg.BaseURL)
|
|
}
|
|
|
|
type profileGetter func(string, map[string]string) (*http.Response, string)
|
|
|
|
func assertProfileVersionLists(t *testing.T, get profileGetter) {
|
|
t.Helper()
|
|
for _, registry := range []struct{ path, old, middle, newest string }{
|
|
{"npm/demo", `"1.0.0"`, `"2.0.0"`, `"3.0.0"`},
|
|
{"cargo/de/mo/demo", `"vers":"1.0.0"`, `"vers":"2.0.0"`, `"vers":"3.0.0"`},
|
|
{"gem/info/demo", "1.0.0 |", "2.0.0 |", "3.0.0 |"},
|
|
{"go/example.com/demo/@v/list", "v1.0.0", "v2.0.0", "v3.0.0"},
|
|
} {
|
|
t.Run(registry.path, func(t *testing.T) {
|
|
for _, profile := range []struct {
|
|
prefix string
|
|
middle, newest bool
|
|
}{
|
|
{"/cooldown/3d/", true, false}, {"/cooldown/7d/", false, false}, {"/", true, true},
|
|
} {
|
|
resp, body := get(profile.prefix+registry.path, nil)
|
|
if resp.StatusCode != http.StatusOK || !strings.Contains(body, registry.old) ||
|
|
strings.Contains(body, registry.middle) != profile.middle || strings.Contains(body, registry.newest) != profile.newest {
|
|
t.Fatalf("%s%s: status %d, body %s", profile.prefix, registry.path, resp.StatusCode, body)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func assertProfileRouting(t *testing.T, baseURL string, get profileGetter, counts func(string) int) {
|
|
t.Helper()
|
|
for _, profile := range []string{"3d", "7d"} {
|
|
_, body := get("/cooldown/"+profile+"/cargo/config.json", nil)
|
|
if !strings.Contains(body, baseURL+"/cooldown/"+profile+"/cargo/crates/") {
|
|
t.Fatalf("Cargo download URL lost profile: %s", body)
|
|
}
|
|
_, body = get("/cooldown/"+profile+"/npm/demo", nil)
|
|
if !strings.Contains(body, baseURL+"/cooldown/"+profile+"/npm/demo/-/") {
|
|
t.Fatalf("npm download URL lost profile: %s", body)
|
|
}
|
|
}
|
|
loose, _ := get("/cooldown/3d/npm/demo", nil)
|
|
strict, _ := get("/cooldown/7d/npm/demo", map[string]string{"If-None-Match": loose.Header.Get("ETag")})
|
|
if strict.StatusCode != http.StatusOK || strict.Header.Get("ETag") == loose.Header.Get("ETag") {
|
|
t.Fatalf("cross-profile validator returned status %d", strict.StatusCode)
|
|
}
|
|
resp, _ := get("/cooldown/7d/npm/demo", map[string]string{"If-None-Match": strict.Header.Get("ETag")})
|
|
if resp.StatusCode != http.StatusNotModified {
|
|
t.Fatalf("same-profile revalidation returned %d", resp.StatusCode)
|
|
}
|
|
for _, tc := range []struct {
|
|
path string
|
|
status int
|
|
}{
|
|
{"/cooldown/3d/npm/demo/-/demo-2.0.0.tgz", http.StatusOK},
|
|
{"/cooldown/7d/npm/demo/-/demo-2.0.0.tgz", http.StatusNotFound},
|
|
{"/cooldown/3d/npm/demo/-/demo-2.0.0.tgz", http.StatusOK},
|
|
{"/cooldown/7d/go/example.com/demo/@v/v2.0.0.mod", http.StatusForbidden},
|
|
{"/cooldown/unknown/npm/demo", http.StatusNotFound},
|
|
{"/cooldown/3d/ui/", http.StatusNotFound},
|
|
{"/cooldown/3d/api/packages", http.StatusNotFound},
|
|
{"/cooldown/3d/metrics", http.StatusNotFound},
|
|
} {
|
|
resp, _ := get(tc.path, nil)
|
|
if resp.StatusCode != tc.status {
|
|
t.Errorf("%s: status %d, want %d", tc.path, resp.StatusCode, tc.status)
|
|
}
|
|
}
|
|
if got := counts("/npm/demo/-/demo-2.0.0.tgz"); got != 1 {
|
|
t.Errorf("artifact upstream fetches = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func assertConcurrentProfileRequests(t *testing.T, client *http.Client, baseURL string) {
|
|
t.Helper()
|
|
var wg sync.WaitGroup
|
|
for range 8 {
|
|
for _, profile := range []string{"3d", "7d"} {
|
|
wg.Go(func() {
|
|
resp, err := client.Get(baseURL + "/cooldown/" + profile + "/npm/demo")
|
|
if err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
var doc struct{ Versions map[string]json.RawMessage }
|
|
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
_, middle := doc.Versions["2.0.0"]
|
|
if middle != (profile == "3d") {
|
|
t.Errorf("%s served another profile's versions", profile)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
func profileTestUpstream(t *testing.T) (*httptest.Server, func(string) int) {
|
|
t.Helper()
|
|
var mu sync.Mutex
|
|
counts := make(map[string]int)
|
|
ages := []time.Duration{10 * 24 * time.Hour, 5 * 24 * time.Hour, 24 * time.Hour}
|
|
var upstream *httptest.Server
|
|
upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
counts[r.URL.Path]++
|
|
mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch r.URL.Path {
|
|
case "/npm/demo":
|
|
versions := map[string]any{}
|
|
times := map[string]string{}
|
|
for i, age := range ages {
|
|
version := fmt.Sprintf("%d.0.0", i+1)
|
|
times[version] = time.Now().Add(-age).UTC().Format(time.RFC3339)
|
|
versions[version] = map[string]any{"name": "demo", "version": version, "dist": map[string]string{
|
|
"tarball": upstream.URL + "/npm/demo/-/demo-" + version + ".tgz",
|
|
}}
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"name": "demo", "versions": versions, "time": times, "dist-tags": map[string]string{"latest": "3.0.0"}})
|
|
case "/npm/demo/-/demo-2.0.0.tgz":
|
|
_, _ = io.WriteString(w, "cached package bytes")
|
|
case "/cargo/de/mo/demo":
|
|
for i, age := range ages {
|
|
_, _ = fmt.Fprintf(w, "{\"name\":\"demo\",\"vers\":\"%d.0.0\",\"deps\":[],\"features\":{},\"yanked\":false,\"pubtime\":%q}\n", i+1, time.Now().Add(-age).UTC().Format(time.RFC3339))
|
|
}
|
|
case "/gem/info/demo":
|
|
_, _ = io.WriteString(w, "---\n1.0.0 |checksum:a\n2.0.0 |checksum:b\n3.0.0 |checksum:c\n")
|
|
case "/gem/api/v1/versions/demo.json":
|
|
versions := make([]map[string]string, 0, len(ages))
|
|
for i, age := range ages {
|
|
versions = append(versions, map[string]string{"number": fmt.Sprintf("%d.0.0", i+1), "created_at": time.Now().Add(-age).UTC().Format(time.RFC3339)})
|
|
}
|
|
_ = json.NewEncoder(w).Encode(versions)
|
|
case "/go/example.com/demo/@v/list":
|
|
_, _ = io.WriteString(w, "v1.0.0\nv2.0.0\nv3.0.0\n")
|
|
default:
|
|
for i, age := range ages {
|
|
if r.URL.Path == fmt.Sprintf("/go/example.com/demo/@v/v%d.0.0.info", i+1) {
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"Version": fmt.Sprintf("v%d.0.0", i+1), "Time": time.Now().Add(-age).UTC().Format(time.RFC3339)})
|
|
return
|
|
}
|
|
}
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(upstream.Close)
|
|
return upstream, func(path string) int {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return counts[path]
|
|
}
|
|
}
|