mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-09-16 15:52:05 -04:00
200 lines
7.4 KiB
Go
200 lines
7.4 KiB
Go
|
|
package handler
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"compress/gzip"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"strings"
|
||
|
|
"sync/atomic"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// gzipPayload returns a gzip-compressed copy of data, simulating an origin
|
||
|
|
// that stores pre-compressed index files.
|
||
|
|
func gzipPayload(t *testing.T, data []byte) []byte {
|
||
|
|
t.Helper()
|
||
|
|
var buf bytes.Buffer
|
||
|
|
zw := gzip.NewWriter(&buf)
|
||
|
|
if _, err := zw.Write(data); err != nil {
|
||
|
|
t.Fatalf("compressing payload: %v", err)
|
||
|
|
}
|
||
|
|
if err := zw.Close(); err != nil {
|
||
|
|
t.Fatalf("closing gzip writer: %v", err)
|
||
|
|
}
|
||
|
|
return buf.Bytes()
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestProxyCached_PreservesContentEncodedBytes covers issue #300: an upstream
|
||
|
|
// that serves a signed index with Content-Encoding: gzip must have its bytes
|
||
|
|
// cached and re-served verbatim, with the encoding header replayed, instead of
|
||
|
|
// being transparently decompressed by the HTTP client.
|
||
|
|
func TestProxyCached_PreservesContentEncodedBytes(t *testing.T) {
|
||
|
|
raw := gzipPayload(t, []byte("signed index payload"))
|
||
|
|
|
||
|
|
var available atomic.Bool
|
||
|
|
available.Store(true)
|
||
|
|
var sawAcceptEncoding atomic.Value
|
||
|
|
var upstreamRequests atomic.Int32
|
||
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if !available.Load() {
|
||
|
|
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
upstreamRequests.Add(1)
|
||
|
|
sawAcceptEncoding.Store(r.Header.Get(headerAcceptEncoding))
|
||
|
|
w.Header().Set(headerContentType, "application/octet-stream")
|
||
|
|
w.Header().Set(headerContentEncoding, "gzip")
|
||
|
|
_, _ = w.Write(raw)
|
||
|
|
}))
|
||
|
|
defer upstream.Close()
|
||
|
|
|
||
|
|
proxy, _, _, _ := setupTestProxy(t)
|
||
|
|
proxy.CacheMetadata = true
|
||
|
|
proxy.MetadataTTL = time.Hour
|
||
|
|
proxy.HTTPClient = upstream.Client()
|
||
|
|
|
||
|
|
serve := func() *httptest.ResponseRecorder {
|
||
|
|
w := httptest.NewRecorder()
|
||
|
|
r := httptest.NewRequest(http.MethodGet, "/index", nil)
|
||
|
|
proxy.ProxyCached(w, r, upstream.URL+"/index", "apk", "index-key", "*/*")
|
||
|
|
return w
|
||
|
|
}
|
||
|
|
|
||
|
|
first := serve()
|
||
|
|
if first.Code != http.StatusOK {
|
||
|
|
t.Fatalf("first response status = %d, want 200: %s", first.Code, first.Body.String())
|
||
|
|
}
|
||
|
|
if got, _ := sawAcceptEncoding.Load().(string); got != "identity" {
|
||
|
|
t.Errorf("upstream saw Accept-Encoding %q, want %q", got, "identity")
|
||
|
|
}
|
||
|
|
if !bytes.Equal(first.Body.Bytes(), raw) {
|
||
|
|
t.Errorf("first response altered the upstream bytes: got %d bytes, want %d", first.Body.Len(), len(raw))
|
||
|
|
}
|
||
|
|
if got := first.Header().Get(headerContentEncoding); got != "gzip" {
|
||
|
|
t.Errorf("first response Content-Encoding = %q, want %q", got, "gzip")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Within the TTL and with the upstream down, the cached copy must be
|
||
|
|
// served with the same bytes and encoding.
|
||
|
|
available.Store(false)
|
||
|
|
second := serve()
|
||
|
|
if second.Code != http.StatusOK {
|
||
|
|
t.Fatalf("cached response status = %d, want 200: %s", second.Code, second.Body.String())
|
||
|
|
}
|
||
|
|
if !bytes.Equal(second.Body.Bytes(), raw) {
|
||
|
|
t.Errorf("cached response altered the stored bytes")
|
||
|
|
}
|
||
|
|
if got := second.Header().Get(headerContentEncoding); got != "gzip" {
|
||
|
|
t.Errorf("cached response Content-Encoding = %q, want %q", got, "gzip")
|
||
|
|
}
|
||
|
|
if got := upstreamRequests.Load(); got != 1 {
|
||
|
|
t.Errorf("upstream requests = %d, want 1", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestProxyCached_NoEncodingHeaderForIdentityResponses pins that ordinary
|
||
|
|
// responses do not grow a spurious Content-Encoding header.
|
||
|
|
func TestProxyCached_NoEncodingHeaderForIdentityResponses(t *testing.T) {
|
||
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
|
|
w.Header().Set(headerContentType, contentTypeJSON)
|
||
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
||
|
|
}))
|
||
|
|
defer upstream.Close()
|
||
|
|
|
||
|
|
proxy, _, _, _ := setupTestProxy(t)
|
||
|
|
proxy.CacheMetadata = true
|
||
|
|
proxy.MetadataTTL = time.Hour
|
||
|
|
proxy.HTTPClient = upstream.Client()
|
||
|
|
|
||
|
|
w := httptest.NewRecorder()
|
||
|
|
r := httptest.NewRequest(http.MethodGet, "/meta", nil)
|
||
|
|
proxy.ProxyCached(w, r, upstream.URL+"/meta", "npm", "meta-key", contentTypeJSON)
|
||
|
|
if w.Code != http.StatusOK {
|
||
|
|
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
|
||
|
|
}
|
||
|
|
if got := w.Header().Get(headerContentEncoding); got != "" {
|
||
|
|
t.Errorf("Content-Encoding = %q, want empty", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestProxyMetadataStream_PreservesSignedBytesWithoutClientEncoding pins the
|
||
|
|
// uncached streaming path (the default, since cache_metadata is off) for the
|
||
|
|
// realistic client that sends no Accept-Encoding: the proxy must request
|
||
|
|
// identity upstream so Go does not transparently decompress a signed index,
|
||
|
|
// and the raw bytes plus the Content-Encoding header must reach the client.
|
||
|
|
func TestProxyMetadataStream_PreservesSignedBytesWithoutClientEncoding(t *testing.T) {
|
||
|
|
raw := gzipPayload(t, []byte("streamed index payload"))
|
||
|
|
var sawAcceptEncoding string
|
||
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
sawAcceptEncoding = r.Header.Get(headerAcceptEncoding)
|
||
|
|
w.Header().Set(headerContentType, "application/octet-stream")
|
||
|
|
w.Header().Set(headerContentEncoding, "gzip")
|
||
|
|
_, _ = w.Write(raw)
|
||
|
|
}))
|
||
|
|
defer upstream.Close()
|
||
|
|
|
||
|
|
proxy, _, _, _ := setupTestProxy(t)
|
||
|
|
proxy.CacheMetadata = false
|
||
|
|
proxy.HTTPClient = upstream.Client()
|
||
|
|
|
||
|
|
w := httptest.NewRecorder()
|
||
|
|
// No Accept-Encoding on the client request -- the apk/apt/dnf case.
|
||
|
|
r := httptest.NewRequest(http.MethodGet, "/index", nil)
|
||
|
|
proxy.ProxyCached(w, r, upstream.URL+"/index", "apk", "stream-key", "*/*")
|
||
|
|
|
||
|
|
if w.Code != http.StatusOK {
|
||
|
|
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
|
||
|
|
}
|
||
|
|
if sawAcceptEncoding != "identity" {
|
||
|
|
t.Errorf("upstream saw Accept-Encoding %q, want %q", sawAcceptEncoding, "identity")
|
||
|
|
}
|
||
|
|
if !bytes.Equal(w.Body.Bytes(), raw) {
|
||
|
|
t.Errorf("streamed response altered the upstream bytes: got %d bytes, want %d", w.Body.Len(), len(raw))
|
||
|
|
}
|
||
|
|
if got := w.Header().Get(headerContentEncoding); got != "gzip" {
|
||
|
|
t.Errorf("Content-Encoding = %q, want %q", got, "gzip")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestFetchOrCacheMetadata_DirectCallersKeepTransparentCompression pins that
|
||
|
|
// the parsing/rewriting ecosystems (npm, pypi, cargo, helm, ...) that call
|
||
|
|
// FetchOrCacheMetadata directly are NOT forced to identity: they keep Go's
|
||
|
|
// transparent transfer compression and receive decoded bytes, so a gzip-only
|
||
|
|
// upstream does not regress them (no wire-size blowup, no parse failures).
|
||
|
|
func TestFetchOrCacheMetadata_DirectCallersKeepTransparentCompression(t *testing.T) {
|
||
|
|
plaintext := []byte(`{"name":"demo","versions":{"1.0.0":{}}}`)
|
||
|
|
var sawAcceptEncoding string
|
||
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
sawAcceptEncoding = r.Header.Get(headerAcceptEncoding)
|
||
|
|
// Serve gzip only when the client accepts it, like a real CDN.
|
||
|
|
if strings.Contains(r.Header.Get(headerAcceptEncoding), "gzip") {
|
||
|
|
w.Header().Set(headerContentType, contentTypeJSON)
|
||
|
|
w.Header().Set(headerContentEncoding, "gzip")
|
||
|
|
_, _ = w.Write(gzipPayload(t, plaintext))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
w.Header().Set(headerContentType, contentTypeJSON)
|
||
|
|
_, _ = w.Write(plaintext)
|
||
|
|
}))
|
||
|
|
defer upstream.Close()
|
||
|
|
|
||
|
|
proxy, _, _, _ := setupTestProxy(t)
|
||
|
|
proxy.CacheMetadata = true
|
||
|
|
proxy.MetadataTTL = time.Hour
|
||
|
|
proxy.HTTPClient = upstream.Client()
|
||
|
|
|
||
|
|
body, _, err := proxy.FetchOrCacheMetadata(t.Context(), "npm", "demo", upstream.URL+"/demo", contentTypeJSON)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("FetchOrCacheMetadata() error = %v", err)
|
||
|
|
}
|
||
|
|
// The default transport adds Accept-Encoding: gzip and transparently
|
||
|
|
// decompresses, so the caller sees decoded JSON regardless of the wire form.
|
||
|
|
if sawAcceptEncoding == "identity" {
|
||
|
|
t.Errorf("direct caller forced identity; want transparent compression")
|
||
|
|
}
|
||
|
|
if !bytes.Equal(body, plaintext) {
|
||
|
|
t.Errorf("direct caller got %q, want decoded %q", body, plaintext)
|
||
|
|
}
|
||
|
|
}
|