mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 23:59:16 -05:00
CI, the release binaries and swagger take their Go version from go.mod, which pinned 1.26.7 and so missed the stdlib fixes in 1.26.8 to 1.26.10. A toolchain line now sets 1.27.3 for them, and the Docker builder uses the same version. The go line stays at 1.26.7, so source builds with an older local Go still work. golangci-lint 2.13.1 can't read Go 1.27.2+ export data, so CI moves to 2.15.0. Its errcheck flags two test cleanups that dropped the server's exit error; they now check it.
542 lines
21 KiB
Go
542 lines
21 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"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()))
|
|
r.Mount("/generic", http.StripPrefix("/generic", handler.NewGenericHandler(proxy, nil).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},
|
|
{name: "sha256 mismatch", target: "/api/hosted/npm/request/2.88.3?sha256=" + strings.Repeat("0", 64), token: testHostedToken, body: requestTarball, wantStatus: http.StatusBadRequest},
|
|
{name: "malformed sha256", target: "/api/hosted/npm/request/2.88.3?sha256=abc", token: testHostedToken, body: requestTarball, wantStatus: http.StatusBadRequest},
|
|
{name: "generic without file", target: "/api/hosted/generic/request/2.88.3", token: testHostedToken, body: "binary", wantStatus: http.StatusBadRequest},
|
|
{name: "generic name with two segments", target: "/api/hosted/generic/acme/tools/1.0.0/tool.bin", token: testHostedToken, body: "binary", wantStatus: http.StatusBadRequest},
|
|
}
|
|
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)
|
|
if err := <-startErr; !errors.Is(err, http.ErrServerClosed) {
|
|
t.Errorf("server stopped with %v", err)
|
|
}
|
|
})
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestHostedRepushIsRefused(t *testing.T) {
|
|
s := newHostedTestServer(t)
|
|
const target = "/api/hosted/npm/request/2.88.3"
|
|
|
|
if rec := s.do(t, http.MethodPut, target, testHostedToken, requestTarball); rec.Code != http.StatusCreated {
|
|
t.Fatalf("first PUT status = %d, body %s", rec.Code, rec.Body)
|
|
}
|
|
if rec := s.do(t, http.MethodPut, target, testHostedToken, requestTarball); rec.Code != http.StatusConflict {
|
|
t.Fatalf("second PUT status = %d, want 409, body %s", rec.Code, rec.Body)
|
|
}
|
|
if rec := s.do(t, http.MethodDelete, target, testHostedToken, ""); rec.Code != http.StatusNoContent {
|
|
t.Fatalf("DELETE status = %d", rec.Code)
|
|
}
|
|
if rec := s.do(t, http.MethodPut, target, testHostedToken, requestTarball); rec.Code != http.StatusCreated {
|
|
t.Fatalf("PUT after DELETE status = %d, want 201, body %s", rec.Code, rec.Body)
|
|
}
|
|
}
|
|
|
|
func TestHostedComposerRepushUnderAnotherCaseIsRefused(t *testing.T) {
|
|
s := newHostedTestServer(t)
|
|
original := composerZip(`{"name":"acme/private","description":"first"}`)
|
|
if rec := s.do(t, http.MethodPut, "/api/hosted/composer/acme/private/1.0.0", testHostedToken, original); rec.Code != http.StatusCreated {
|
|
t.Fatalf("first PUT status = %d, body %s", rec.Code, rec.Body)
|
|
}
|
|
if rec := s.do(t, http.MethodPut, "/api/hosted/composer/acme/Private/1.0.0", testHostedToken,
|
|
composerZip(`{"name":"acme/private","description":"second"}`)); rec.Code != http.StatusConflict {
|
|
t.Fatalf("PUT of acme/Private status = %d, want 409, body %s", rec.Code, rec.Body)
|
|
}
|
|
rec := s.do(t, http.MethodGet, "/composer/files/acme/private/1.0.0/private-1.0.0.zip", "", "")
|
|
if rec.Code != http.StatusOK || rec.Body.String() != original {
|
|
t.Fatalf("download status = %d, want 200 and the first archive", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHostedPushWithMatchingSHA256(t *testing.T) {
|
|
s := newHostedTestServer(t)
|
|
digest := sha256.Sum256([]byte(requestTarball))
|
|
target := "/api/hosted/npm/request/2.88.3?sha256=" + strings.ToUpper(hex.EncodeToString(digest[:]))
|
|
|
|
if rec := s.do(t, http.MethodPut, target, testHostedToken, requestTarball); rec.Code != http.StatusCreated {
|
|
t.Fatalf("PUT status = %d, body %s", rec.Code, rec.Body)
|
|
}
|
|
}
|
|
|
|
func TestHostedGenericFiles(t *testing.T) {
|
|
s := newHostedTestServer(t)
|
|
const (
|
|
arm64 = "tool-linux-arm64.tar.gz"
|
|
amd64 = "tool-linux-amd64.tar.gz"
|
|
)
|
|
|
|
for _, file := range []string{arm64, amd64} {
|
|
rec := s.do(t, http.MethodPut, "/api/hosted/generic/tools/1.2.0/"+file, testHostedToken, "bytes of "+file)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("PUT %s status = %d, body %s", file, rec.Code, rec.Body)
|
|
}
|
|
var pushed HostedPushResponse
|
|
if err := json.NewDecoder(rec.Body).Decode(&pushed); err != nil {
|
|
t.Fatalf("decoding PUT response: %v", err)
|
|
}
|
|
digest := sha256.Sum256([]byte("bytes of " + file))
|
|
if pushed.Filename != file || pushed.Version != "1.2.0" || pushed.SHA256 != hex.EncodeToString(digest[:]) {
|
|
t.Errorf("PUT response = %+v", pushed)
|
|
}
|
|
}
|
|
|
|
rec := s.do(t, http.MethodGet, "/generic/hosted/tools/1.2.0/"+arm64, "", "")
|
|
if rec.Code != http.StatusOK || rec.Body.String() != "bytes of "+arm64 {
|
|
t.Fatalf("download status = %d, body %q", rec.Code, rec.Body)
|
|
}
|
|
digest := sha256.Sum256([]byte("bytes of " + arm64))
|
|
if etag := rec.Header().Get("ETag"); etag != `"`+hex.EncodeToString(digest[:])+`"` {
|
|
t.Errorf("ETag = %q, want the sha256", etag)
|
|
}
|
|
if rec := s.do(t, http.MethodHead, "/generic/hosted/tools/1.2.0/"+arm64, "", ""); rec.Code != http.StatusOK {
|
|
t.Errorf("HEAD status = %d", rec.Code)
|
|
}
|
|
|
|
listed := s.do(t, http.MethodGet, "/api/hosted/generic/tools", "", "")
|
|
var list HostedListResponse
|
|
if err := json.NewDecoder(listed.Body).Decode(&list); err != nil {
|
|
t.Fatalf("decoding list: %v", err)
|
|
}
|
|
if len(list.Artifacts) != 2 {
|
|
t.Fatalf("listed %d files, want 2: %+v", len(list.Artifacts), list.Artifacts)
|
|
}
|
|
|
|
if rec := s.do(t, http.MethodDelete, "/api/hosted/generic/tools/1.2.0/"+arm64, testHostedToken, ""); rec.Code != http.StatusNoContent {
|
|
t.Fatalf("DELETE file status = %d, body %s", rec.Code, rec.Body)
|
|
}
|
|
if rec := s.do(t, http.MethodGet, "/generic/hosted/tools/1.2.0/"+arm64, "", ""); rec.Code != http.StatusNotFound {
|
|
t.Errorf("download of deleted file status = %d, want 404", rec.Code)
|
|
}
|
|
if rec := s.do(t, http.MethodGet, "/generic/hosted/tools/1.2.0/"+amd64, "", ""); rec.Code != http.StatusOK {
|
|
t.Errorf("download of the other file status = %d, want 200", rec.Code)
|
|
}
|
|
if rec := s.do(t, http.MethodDelete, "/api/hosted/generic/tools/1.2.0/"+arm64, testHostedToken, ""); rec.Code != http.StatusNotFound {
|
|
t.Errorf("second DELETE status = %d, want 404", rec.Code)
|
|
}
|
|
|
|
for _, path := range []string{
|
|
"/generic/hosted/tools/1.2.0/missing.bin",
|
|
"/generic/hosted/tools/1.2.0",
|
|
"/generic/hosted/tools/1.2.0/a/b",
|
|
} {
|
|
if rec := s.do(t, http.MethodGet, path, "", ""); rec.Code != http.StatusNotFound {
|
|
t.Errorf("GET %s status = %d, want 404", path, rec.Code)
|
|
}
|
|
}
|
|
if *s.fetches != 0 || *s.upstreamRequests != 0 {
|
|
t.Errorf("hosted generic files went upstream: %d fetches, %d requests", *s.fetches, *s.upstreamRequests)
|
|
}
|
|
}
|
|
|
|
func TestHostedGenericFileIsServedByTheRunningServer(t *testing.T) {
|
|
base := startHostedServer(t, config.HostedConfig{Enabled: true, Token: testHostedToken})
|
|
if status := putBody(t, base+"/api/hosted/generic/tools/1.2.0/tool.tar.gz", testHostedToken, "tool bytes"); status != http.StatusCreated {
|
|
t.Fatalf("push status = %d, want 201", status)
|
|
}
|
|
|
|
resp, err := http.Get(base + "/generic/hosted/tools/1.2.0/tool.tar.gz")
|
|
if err != nil {
|
|
t.Fatalf("GET file: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK || string(body) != "tool bytes" {
|
|
t.Errorf("download status = %d, body %q", resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
// unreadBody fails the test if the handler reads it.
|
|
type unreadBody struct{ t *testing.T }
|
|
|
|
func (b unreadBody) Read([]byte) (int, error) {
|
|
b.t.Error("body of a refused push was read")
|
|
return 0, io.EOF
|
|
}
|
|
|
|
func TestHostedRepushIsRefusedBeforeTheBodyIsRead(t *testing.T) {
|
|
s := newHostedTestServer(t)
|
|
const target = "/api/hosted/generic/tools/1.2.0/tool.tar.gz"
|
|
if rec := s.do(t, http.MethodPut, target, testHostedToken, "first"); rec.Code != http.StatusCreated {
|
|
t.Fatalf("first PUT status = %d, body %s", rec.Code, rec.Body)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPut, target, unreadBody{t})
|
|
req.Header.Set("Authorization", "Bearer "+testHostedToken)
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusConflict {
|
|
t.Fatalf("second PUT status = %d, want 409, body %s", rec.Code, rec.Body)
|
|
}
|
|
}
|
|
|
|
func TestHostedDeleteRejectsMalformedPaths(t *testing.T) {
|
|
s := newHostedTestServer(t)
|
|
for _, target := range []string{
|
|
"/api/hosted/generic/tools/1.2.0/%2e%2e",
|
|
"/api/hosted/generic/tools/1.2.0/tool%0a",
|
|
"/api/hosted/npm/request/2.88.3/request-2.88.3.tgz/extra",
|
|
"/api/hosted/composer/monolog/3.5.0",
|
|
"/api/hosted/cargo/serde/1.0.200",
|
|
} {
|
|
if rec := s.do(t, http.MethodDelete, target, testHostedToken, ""); rec.Code != http.StatusBadRequest {
|
|
t.Errorf("DELETE %s status = %d, want 400, body %s", target, rec.Code, rec.Body)
|
|
}
|
|
}
|
|
}
|