mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 23:59:16 -05:00
With hosted packages enabled, a package's p2 metadata lists its pushed versions, built from composer.json without the root-only keys, with a zip dist at the proxy and the time they were pushed. dist.reference is the archive's sha256 so a re-push isn't served from Composer's cache, and shasum is left empty. Dev versions go to the ~dev file and tagged versions to the other. A pushed version takes the place of an upstream entry with the same version, with or without a leading v. Pushed entries follow the upstream ones and go through the same minified writer, so fields an earlier upstream entry set and the pushed one lacks are written as __unset rather than inherited. When the upstream doesn't know the package, the document holds only the pushed versions. Like npm, documents with pushed versions bypass the rewrite cache. Refs #134
362 lines
13 KiB
Go
362 lines
13 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/git-pkgs/proxy/internal/config"
|
|
"github.com/git-pkgs/proxy/internal/database"
|
|
"github.com/git-pkgs/proxy/internal/handler"
|
|
"github.com/git-pkgs/proxy/internal/storage"
|
|
"github.com/git-pkgs/registries/fetch"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
const (
|
|
testHostedToken = "push-token"
|
|
testHostedMaxUpload = 4096
|
|
)
|
|
|
|
type hostedTestServer struct {
|
|
router http.Handler
|
|
fetches *int
|
|
upstreamRequests *int
|
|
}
|
|
|
|
// countingFetcher fails every fetch, so a test fails loudly if the proxy goes
|
|
// upstream for something it should serve from the pushed copy.
|
|
type countingFetcher struct{ calls *int }
|
|
|
|
func (f countingFetcher) Fetch(ctx context.Context, url string) (*fetch.Artifact, error) {
|
|
return f.FetchWithHeaders(ctx, url, nil)
|
|
}
|
|
|
|
func (f countingFetcher) FetchWithHeaders(context.Context, string, http.Header) (*fetch.Artifact, error) {
|
|
*f.calls++
|
|
return nil, fetch.ErrNotFound
|
|
}
|
|
|
|
func (f countingFetcher) Head(context.Context, string) (int64, string, error) {
|
|
*f.calls++
|
|
return 0, "", fetch.ErrNotFound
|
|
}
|
|
|
|
func newHostedTestServer(t *testing.T) hostedTestServer {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
db, err := database.Create(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("database.Create: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
store, err := storage.OpenBucket(context.Background(), "file://"+filepath.Join(dir, "artifacts"))
|
|
if err != nil {
|
|
t.Fatalf("storage.OpenBucket: %v", err)
|
|
}
|
|
|
|
calls := 0
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
proxy := handler.NewProxy(db, store, countingFetcher{calls: &calls}, fetch.NewResolver(), logger)
|
|
proxy.HostedEnabled = true
|
|
|
|
upstreamRequests := 0
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
upstreamRequests++
|
|
http.NotFound(w, nil)
|
|
}))
|
|
t.Cleanup(upstream.Close)
|
|
|
|
r := chi.NewRouter()
|
|
registerHostedRoutes(r, NewHostedAPIHandler(proxy, testHostedMaxUpload, logger), testHostedToken)
|
|
r.Mount("/npm", http.StripPrefix("/npm", handler.NewNPMHandler(proxy, "http://proxy.test", upstream.URL).Routes()))
|
|
composer := handler.NewComposerHandlerWithUpstreams(proxy, "http://proxy.test", upstream.URL, upstream.URL)
|
|
r.Mount("/composer", http.StripPrefix("/composer", composer.Routes()))
|
|
return hostedTestServer{router: r, fetches: &calls, upstreamRequests: &upstreamRequests}
|
|
}
|
|
|
|
func (s hostedTestServer) do(t *testing.T, method, target, token, body string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var reader io.Reader
|
|
if body != "" {
|
|
reader = strings.NewReader(body)
|
|
}
|
|
req := httptest.NewRequest(method, target, reader)
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func TestHostedPushIsServedByTheEcosystemHandler(t *testing.T) {
|
|
s := newHostedTestServer(t)
|
|
|
|
rec := s.do(t, http.MethodPut, "/api/hosted/npm/request/2.88.3", testHostedToken, requestTarball)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("PUT status = %d, body %s", rec.Code, rec.Body)
|
|
}
|
|
var pushed HostedPushResponse
|
|
if err := json.NewDecoder(rec.Body).Decode(&pushed); err != nil {
|
|
t.Fatalf("decoding PUT response: %v", err)
|
|
}
|
|
if pushed.Ecosystem != "npm" || pushed.Name != "request" || pushed.Version != "2.88.3" ||
|
|
pushed.Filename != "request-2.88.3.tgz" || pushed.Size != int64(len(requestTarball)) {
|
|
t.Errorf("PUT response = %+v", pushed)
|
|
}
|
|
|
|
rec = s.do(t, http.MethodGet, "/npm/request/-/request-2.88.3.tgz", "", "")
|
|
if rec.Code != http.StatusOK || rec.Body.String() != requestTarball {
|
|
t.Fatalf("download status = %d, body %q, want 200 and the pushed tarball", rec.Code, rec.Body)
|
|
}
|
|
if *s.fetches != 0 {
|
|
t.Errorf("download contacted the upstream %d times", *s.fetches)
|
|
}
|
|
}
|
|
|
|
func TestHostedPushIsServedByTheComposerHandler(t *testing.T) {
|
|
s := newHostedTestServer(t)
|
|
|
|
rec := s.do(t, http.MethodPut, "/api/hosted/composer/monolog/monolog/3.5.0", testHostedToken, monologZip)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("PUT status = %d, body %s", rec.Code, rec.Body)
|
|
}
|
|
|
|
rec = s.do(t, http.MethodGet, "/composer/files/monolog/monolog/3.5.0/monolog-3.5.0.zip", "", "")
|
|
if rec.Code != http.StatusOK || rec.Body.String() != monologZip {
|
|
t.Fatalf("download status = %d, body %q, want 200 and the pushed archive", rec.Code, rec.Body)
|
|
}
|
|
if *s.upstreamRequests != 0 || *s.fetches != 0 {
|
|
t.Errorf("download contacted the upstream (%d metadata requests, %d fetches)", *s.upstreamRequests, *s.fetches)
|
|
}
|
|
}
|
|
|
|
func TestComposerDownloadOfUnpushedFileStillNeedsUpstreamMetadata(t *testing.T) {
|
|
s := newHostedTestServer(t)
|
|
if rec := s.do(t, http.MethodPut, "/api/hosted/composer/monolog/monolog/3.5.0", testHostedToken, monologZip); rec.Code != http.StatusCreated {
|
|
t.Fatalf("PUT status = %d, body %s", rec.Code, rec.Body)
|
|
}
|
|
|
|
rec := s.do(t, http.MethodGet, "/composer/files/monolog/monolog/3.5.0/other.zip", "", "")
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("download of another filename status = %d, want 404 from the upstream lookup", rec.Code)
|
|
}
|
|
if *s.upstreamRequests == 0 {
|
|
t.Error("a filename that was not pushed was served without the upstream metadata lookup")
|
|
}
|
|
}
|
|
|
|
func TestHostedPushScopedNpmPackage(t *testing.T) {
|
|
s := newHostedTestServer(t)
|
|
|
|
rec := s.do(t, http.MethodPut, "/api/hosted/npm/@types/node/20.1.0", testHostedToken, nodeTarball)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("PUT status = %d, body %s", rec.Code, rec.Body)
|
|
}
|
|
rec = s.do(t, http.MethodGet, "/npm/@types/node/-/node-20.1.0.tgz", "", "")
|
|
if rec.Code != http.StatusOK || rec.Body.String() != nodeTarball {
|
|
t.Fatalf("download status = %d, body %q, want 200 and the pushed tarball", rec.Code, rec.Body)
|
|
}
|
|
}
|
|
|
|
func TestHostedListAndDelete(t *testing.T) {
|
|
s := newHostedTestServer(t)
|
|
if rec := s.do(t, http.MethodPut, "/api/hosted/npm/request/2.88.3", testHostedToken, requestTarball); rec.Code != http.StatusCreated {
|
|
t.Fatalf("PUT status = %d, body %s", rec.Code, rec.Body)
|
|
}
|
|
|
|
rec := s.do(t, http.MethodGet, "/api/hosted/npm/request", "", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET status = %d, body %s", rec.Code, rec.Body)
|
|
}
|
|
var listed HostedListResponse
|
|
if err := json.NewDecoder(rec.Body).Decode(&listed); err != nil {
|
|
t.Fatalf("decoding GET response: %v", err)
|
|
}
|
|
if listed.Ecosystem != "npm" || listed.Name != "request" || len(listed.Artifacts) != 1 || listed.Artifacts[0].Version != "2.88.3" {
|
|
t.Errorf("GET response = %+v", listed)
|
|
}
|
|
|
|
if rec := s.do(t, http.MethodDelete, "/api/hosted/npm/request/2.88.3", "", ""); rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("DELETE without token status = %d, want 401", rec.Code)
|
|
}
|
|
if rec := s.do(t, http.MethodDelete, "/api/hosted/npm/request/2.88.3", testHostedToken, ""); rec.Code != http.StatusNoContent {
|
|
t.Errorf("DELETE status = %d, body %s", rec.Code, rec.Body)
|
|
}
|
|
if rec := s.do(t, http.MethodDelete, "/api/hosted/npm/request/2.88.3", testHostedToken, ""); rec.Code != http.StatusNotFound {
|
|
t.Errorf("second DELETE status = %d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHostedPushRejections(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
target string
|
|
token string
|
|
body string
|
|
wantStatus int
|
|
}{
|
|
{name: "no token", target: "/api/hosted/npm/request/2.88.3", body: requestTarball, wantStatus: http.StatusUnauthorized},
|
|
{name: "wrong token", target: "/api/hosted/npm/request/2.88.3", token: "guess", body: requestTarball, wantStatus: http.StatusUnauthorized},
|
|
{name: "unsupported ecosystem", target: "/api/hosted/cargo/serde/1.0.200", token: testHostedToken, body: requestTarball, wantStatus: http.StatusBadRequest},
|
|
{name: "composer name without vendor", target: "/api/hosted/composer/monolog/3.5.0", token: testHostedToken, body: requestTarball, wantStatus: http.StatusBadRequest},
|
|
{name: "missing version", target: "/api/hosted/npm/request", token: testHostedToken, body: requestTarball, wantStatus: http.StatusBadRequest},
|
|
{name: "not an archive", target: "/api/hosted/npm/request/2.88.3", token: testHostedToken, body: "plain text", wantStatus: http.StatusBadRequest},
|
|
{name: "manifest of another version", target: "/api/hosted/npm/request/2.88.4", token: testHostedToken, body: requestTarball, wantStatus: http.StatusBadRequest},
|
|
{name: "empty body", target: "/api/hosted/npm/request/2.88.3", token: testHostedToken, wantStatus: http.StatusBadRequest},
|
|
{name: "too large", target: "/api/hosted/npm/request/2.88.3", token: testHostedToken, body: strings.Repeat("x", testHostedMaxUpload+1), wantStatus: http.StatusRequestEntityTooLarge},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
s := newHostedTestServer(t)
|
|
rec := s.do(t, http.MethodPut, tt.target, tt.token, tt.body)
|
|
if rec.Code != tt.wantStatus {
|
|
t.Fatalf("PUT status = %d, want %d, body %s", rec.Code, tt.wantStatus, rec.Body)
|
|
}
|
|
listed := s.do(t, http.MethodGet, "/api/hosted/npm/request", "", "")
|
|
if strings.Contains(listed.Body.String(), "2.88.3") {
|
|
t.Error("rejected push was stored")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHostedListUnsupportedEcosystem(t *testing.T) {
|
|
s := newHostedTestServer(t)
|
|
if rec := s.do(t, http.MethodGet, "/api/hosted/maven/junit", "", ""); rec.Code != http.StatusBadRequest {
|
|
t.Errorf("GET status = %d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
func startHostedServer(t *testing.T, hosted config.HostedConfig) string {
|
|
t.Helper()
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("reserving proxy address: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = listener.Close() })
|
|
|
|
tempDir := t.TempDir()
|
|
cfg := config.Default()
|
|
cfg.Listen = listener.Addr().String()
|
|
cfg.BaseURL = "http://" + cfg.Listen
|
|
cfg.Database.Path = filepath.Join(tempDir, "proxy.db")
|
|
cfg.Storage.URL = "file://" + filepath.Join(tempDir, "artifacts")
|
|
cfg.Hosted = hosted
|
|
upstream := httptest.NewServer(http.NotFoundHandler())
|
|
t.Cleanup(upstream.Close)
|
|
cfg.Upstream.NPM = upstream.URL
|
|
cfg.Upstream.AllowLoopback = true
|
|
if err := cfg.Validate(); err != nil {
|
|
t.Fatalf("validating config: %v", err)
|
|
}
|
|
|
|
proxyServer, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)), BuildInfo{Version: "test", Commit: "test"})
|
|
if err != nil {
|
|
t.Fatalf("creating server: %v", err)
|
|
}
|
|
startErr := make(chan error, 1)
|
|
go func() { startErr <- proxyServer.Start(listener) }()
|
|
t.Cleanup(func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = proxyServer.Shutdown(ctx)
|
|
<-startErr
|
|
})
|
|
return cfg.BaseURL
|
|
}
|
|
|
|
func putArchive(t *testing.T, url, token string) int {
|
|
t.Helper()
|
|
return putBody(t, url, token, requestTarball)
|
|
}
|
|
|
|
func putBody(t *testing.T, url, token, body string) int {
|
|
t.Helper()
|
|
req, err := http.NewRequest(http.MethodPut, url, strings.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("building request: %v", err)
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("PUT %s: %v", url, err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
return resp.StatusCode
|
|
}
|
|
|
|
func TestHostedRoutesFollowConfig(t *testing.T) {
|
|
disabled := startHostedServer(t, config.HostedConfig{})
|
|
if status := putArchive(t, disabled+"/api/hosted/npm/request/2.88.3", testHostedToken); status == http.StatusCreated {
|
|
t.Error("push accepted with hosted.enabled false")
|
|
}
|
|
|
|
enabled := startHostedServer(t, config.HostedConfig{Enabled: true, Token: testHostedToken})
|
|
if status := putArchive(t, enabled+"/api/hosted/npm/request/2.88.3", ""); status != http.StatusUnauthorized {
|
|
t.Errorf("push without token status = %d, want 401", status)
|
|
}
|
|
if status := putArchive(t, enabled+"/api/hosted/npm/request/2.88.3", testHostedToken); status != http.StatusCreated {
|
|
t.Errorf("push with token status = %d, want 201", status)
|
|
}
|
|
}
|
|
|
|
func TestHostedNpmPackageIsListedByTheRunningServer(t *testing.T) {
|
|
base := startHostedServer(t, config.HostedConfig{Enabled: true, Token: testHostedToken})
|
|
private := npmTarball(`{"name":"@acme/private","version":"1.0.0"}`)
|
|
if status := putBody(t, base+"/api/hosted/npm/@acme/private/1.0.0", testHostedToken, private); status != http.StatusCreated {
|
|
t.Fatalf("push status = %d, want 201", status)
|
|
}
|
|
|
|
resp, err := http.Get(base + "/npm/@acme%2fprivate")
|
|
if err != nil {
|
|
t.Fatalf("GET packument: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"1.0.0"`) {
|
|
t.Errorf("packument status = %d, body %s", resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
func TestHostedComposerVersionIsInstallableFromMetadata(t *testing.T) {
|
|
s := newHostedTestServer(t)
|
|
if rec := s.do(t, http.MethodPut, "/api/hosted/composer/acme/private/1.0.0", testHostedToken,
|
|
composerZip(`{"name":"acme/private","require":{"php":">=8.1"}}`)); rec.Code != http.StatusCreated {
|
|
t.Fatalf("PUT status = %d, body %s", rec.Code, rec.Body)
|
|
}
|
|
|
|
rec := s.do(t, http.MethodGet, "/composer/p2/acme/private.json", "", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("metadata status = %d, body %s", rec.Code, rec.Body)
|
|
}
|
|
var doc struct {
|
|
Packages map[string][]struct {
|
|
Dist struct {
|
|
URL string `json:"url"`
|
|
} `json:"dist"`
|
|
} `json:"packages"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
|
t.Fatalf("decoding metadata: %v", err)
|
|
}
|
|
versions := doc.Packages["acme/private"]
|
|
if len(versions) != 1 {
|
|
t.Fatalf("metadata = %s", rec.Body)
|
|
}
|
|
distPath := strings.TrimPrefix(versions[0].Dist.URL, "http://proxy.test")
|
|
if rec := s.do(t, http.MethodGet, distPath, "", ""); rec.Code != http.StatusOK {
|
|
t.Errorf("download of %s status = %d", distPath, rec.Code)
|
|
}
|
|
}
|