Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 15:49:16 -05:00
pkg-proxy/internal/handler/stream_artifacts_test.go

411 lines
14 KiB
Go
Raw Permalink Normal View History

Add storage.cache_artifacts to serve artifacts without storing them (#387) * Add storage.passthrough to serve artifacts without storing them With storage.passthrough enabled the proxy streams every artifact from upstream to the client and never writes it to storage or the cache database. Metadata filtering, cooldown and the denylist work as before, so the proxy can sit behind another cache (e.g. an Artifactory remote) purely as a policy layer without holding a second copy of every package. Artifacts whose digest is known up front (OCI, Swift, Helm) are verified while streaming. Their responses are sent chunked and the connection is aborted on a mismatch, so a client never receives a tampered artifact as a complete response. Passthrough is rejected together with scanning, direct_serve and mirror_api, and the mirror command refuses to run with it, since all of them depend on stored artifacts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Address review: abort truncated streams, guard every cache read - serveArtifact aborts the response on any body read error, and when fewer bytes than the declared size were written, not only on a digest mismatch. A failed or short upstream body in passthrough mode no longer reaches the client as a complete 200. - Streamed upstream read failures are logged and counted as stream_failed upstream errors before the response is aborted. - checkCache reports a miss when artifact caching is off, so every cache read path, including Swift archive HEAD requests, ignores entries stored before the mode was enabled. Denylisted versions are still rejected. - Rename storage.passthrough to storage.cache_artifacts (default true, PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes. Tests cover an OCI blob shorter than its Content-Length and an npm tarball whose chunked response ends without the final chunk, both served through the HTTP handlers, plus a Swift HEAD request against a cached archive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Rename Proxy.Passthrough to StreamArtifacts Match the storage.cache_artifacts option: the field says what changes (artifacts are streamed instead of stored) and its zero value keeps the usual caching behaviour for every Proxy built without the server config, such as the mirror command and tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
package handler
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/git-pkgs/artifacts"
"github.com/git-pkgs/cooldown"
"github.com/git-pkgs/proxy/internal/packageurl"
"github.com/git-pkgs/registries/fetch"
)
func newStreamingProxy(t *testing.T, body string) (*Proxy, *mockStorage, *mockFetcher) {
t.Helper()
proxy, _, store, fetcher := setupTestProxy(t)
proxy.StreamArtifacts = true
fetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader(body)),
Size: int64(len(body)),
ContentType: "application/gzip",
}
return proxy, store, fetcher
}
func TestStreamArtifactsFromURLStreamsWithoutStoring(t *testing.T) {
proxy, store, fetcher := newStreamingProxy(t, "fetched content")
result, err := proxy.GetOrFetchArtifactFromURL(context.Background(), "pypi", "newpkg", "1.0.0",
"newpkg-1.0.0.tar.gz", "https://pypi.org/files/newpkg-1.0.0.tar.gz")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer func() { _ = result.Reader.Close() }()
body, err := io.ReadAll(result.Reader)
if err != nil {
t.Fatalf("reading body: %v", err)
}
if string(body) != "fetched content" {
t.Errorf("body = %q, want %q", body, "fetched content")
}
if fetcher.fetchedURL != "https://pypi.org/files/newpkg-1.0.0.tar.gz" {
t.Errorf("fetched URL = %q", fetcher.fetchedURL)
}
if result.Cached {
t.Error("streaming result reported as cached")
}
if result.Artifact.Size != int64(len("fetched content")) {
t.Errorf("Size = %d, want the upstream size", result.Artifact.Size)
}
if result.Artifact.MediaType != "application/gzip" {
t.Errorf("MediaType = %q", result.Artifact.MediaType)
}
if len(store.files) != 0 {
t.Errorf("streaming stored %d files, want none", len(store.files))
}
cached, err := proxy.DB.GetCachedArtifact("pkg:pypi/newpkg", "pkg:pypi/newpkg@1.0.0", "newpkg-1.0.0.tar.gz")
if err != nil {
t.Fatalf("GetCachedArtifact: %v", err)
}
if cached != nil {
t.Error("streaming recorded the artifact in the cache database")
}
}
func TestStreamArtifactsGetOrFetchArtifactStreamsWithoutStoring(t *testing.T) {
proxy, store, fetcher := newStreamingProxy(t, "tarball data")
result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "leftpad", testVersion100, "leftpad-1.0.0.tgz")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer func() { _ = result.Reader.Close() }()
body, _ := io.ReadAll(result.Reader)
if string(body) != "tarball data" {
t.Errorf("body = %q", body)
}
if !fetcher.fetchCalled {
t.Error("upstream was not fetched")
}
if len(store.files) != 0 {
t.Errorf("streaming stored %d files, want none", len(store.files))
}
}
func TestStreamArtifactsIgnoresCachedArtifacts(t *testing.T) {
proxy, _, store, fetcher := setupTestProxy(t)
fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("old bytes"))}
url := "https://pypi.org/files/newpkg-1.0.0.tar.gz"
// Populate the cache in normal mode first.
result, err := proxy.GetOrFetchArtifactFromURL(context.Background(), "pypi", "newpkg", "1.0.0", "newpkg-1.0.0.tar.gz", url)
if err != nil {
t.Fatalf("priming cache: %v", err)
}
_ = result.Reader.Close()
if len(store.files) != 1 {
t.Fatalf("expected the cache to hold the artifact, got %d files", len(store.files))
}
proxy.StreamArtifacts = true
cached, err := proxy.GetCachedArtifact(context.Background(), "pypi", "newpkg", "1.0.0", "newpkg-1.0.0.tar.gz")
if err != nil || cached != nil {
t.Fatalf("GetCachedArtifact = %v, %v; want nil, nil while streaming", cached, err)
}
fetcher.fetchCalled = false
fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("new bytes"))}
result, err = proxy.GetOrFetchArtifactFromURL(context.Background(), "pypi", "newpkg", "1.0.0", "newpkg-1.0.0.tar.gz", url)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer func() { _ = result.Reader.Close() }()
body, _ := io.ReadAll(result.Reader)
if !fetcher.fetchCalled || string(body) != "new bytes" {
t.Errorf("streaming served %q (fetched=%v), want a fresh upstream fetch", body, fetcher.fetchCalled)
}
}
func TestStreamArtifactsVerifiesUpstreamDigest(t *testing.T) {
const content = "blob bytes"
t.Run("matching digest streams the body", func(t *testing.T) {
proxy, _, _ := newStreamingProxy(t, content)
result, err := proxy.GetOrFetchArtifactFromURLWithDigest(context.Background(), "oci", "library/app", "v1",
"blob", "https://registry.test/blob", "sha256:"+sha256Hex(content))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer func() { _ = result.Reader.Close() }()
body, err := io.ReadAll(result.Reader)
if err != nil {
t.Fatalf("reading verified body: %v", err)
}
if string(body) != content {
t.Errorf("body = %q", body)
}
if got := result.Artifact.Digest.Encoded(); got != sha256Hex(content) {
t.Errorf("Digest = %q, want the upstream digest", got)
}
if result.Artifact.Size >= 0 {
t.Errorf("Size = %d, want unknown so the response is chunked", result.Artifact.Size)
}
})
t.Run("mismatched digest fails the read", func(t *testing.T) {
proxy, _, _ := newStreamingProxy(t, content)
result, err := proxy.GetOrFetchArtifactFromURLWithDigest(context.Background(), "oci", "library/app", "v1",
"blob", "https://registry.test/blob", "sha256:"+sha256Hex("other bytes"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer func() { _ = result.Reader.Close() }()
if _, err := io.ReadAll(result.Reader); !errors.Is(err, ErrArtifactDigestMismatch) {
t.Errorf("read error = %v, want ErrArtifactDigestMismatch", err)
}
})
}
func TestServeArtifactAbortsOnStreamedDigestMismatch(t *testing.T) {
proxy, _, _ := newStreamingProxy(t, "blob bytes")
result, err := proxy.GetOrFetchArtifactFromURLWithDigest(context.Background(), "oci", "library/app", "v1",
"blob", "https://registry.test/blob", "sha256:"+sha256Hex("other bytes"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
rec := httptest.NewRecorder()
defer func() {
if r := recover(); r != http.ErrAbortHandler {
t.Fatalf("recovered %v, want http.ErrAbortHandler", r)
}
if rec.Header().Get(headerContentLength) != "" {
t.Errorf("Content-Length = %q, want none so the client sees an unterminated response",
rec.Header().Get(headerContentLength))
}
}()
ServeArtifact(rec, result)
}
func TestNPMDownloadCooldownWhileStreaming(t *testing.T) {
now := time.Now()
packument := `{
"name": "leftpad",
"dist-tags": {"latest": "2.0.0"},
"time": {
"1.0.0": "` + now.Add(-30*24*time.Hour).Format(time.RFC3339) + `",
"2.0.0": "` + now.Add(-1*time.Hour).Format(time.RFC3339) + `"
},
"versions": {"1.0.0": {}, "2.0.0": {}}
}`
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", contentTypeJSON)
_, _ = io.WriteString(w, packument)
}))
defer upstream.Close()
tests := []struct {
name string
version string
wantStatus int
}{
{"published before the window streams the tarball", testVersion100, http.StatusOK},
{"published inside the window is withheld", "2.0.0", http.StatusNotFound},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
proxy, store, fetcher := newStreamingProxy(t, "tarball data")
proxy.HTTPClient = upstream.Client()
proxy.Cooldown = &cooldown.Config{Default: "7d"}
srv := httptest.NewServer(NewNPMHandler(proxy, "http://proxy.test", upstream.URL).Routes())
defer srv.Close()
resp, err := http.Get(srv.URL + "/leftpad/-/leftpad-" + tt.version + ".tgz")
if err != nil {
t.Fatalf("request failed: %v", err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Fatalf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
}
if tt.wantStatus == http.StatusOK && string(body) != "tarball data" {
t.Errorf("body = %q", body)
}
if tt.wantStatus == http.StatusNotFound && fetcher.fetchCalled {
t.Error("fetched a version that is still inside the cooldown window")
}
if len(store.files) != 0 {
t.Errorf("streaming stored %d files, want none", len(store.files))
}
})
}
}
// truncatingUpstream answers every request matching match with raw, then
// closes the connection, so the response body ends early.
func truncatingUpstream(t *testing.T, match func(*http.Request) bool, raw string, fallback http.HandlerFunc) *httptest.Server {
t.Helper()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !match(r) {
fallback(w, r)
return
}
conn, buf, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Errorf("hijack: %v", err)
return
}
_, _ = buf.WriteString(raw)
_ = buf.Flush()
_ = conn.Close()
}))
t.Cleanup(upstream.Close)
return upstream
}
// requireIncompleteResponse fails unless reading the response surfaces an error,
// i.e. the client cannot mistake the body for a complete download.
func requireIncompleteResponse(t *testing.T, url string) {
t.Helper()
resp, err := http.Get(url)
if err != nil {
return
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err == nil {
t.Fatalf("client read a complete %d response (Content-Length %q, body %q), want an incomplete one",
resp.StatusCode, resp.Header.Get(headerContentLength), body)
}
}
func useRealFetcher(t *testing.T, proxy *Proxy, upstream *httptest.Server) {
t.Helper()
proxy.HTTPClient = upstream.Client()
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(upstream.Client()), fetch.WithMaxRetries(0))
proxy.Fetcher = fetcher
t.Cleanup(func() { _ = fetcher.Close() })
}
func TestStreamArtifactsOCIBlobShorterThanContentLengthIsIncomplete(t *testing.T) {
digest := "sha256:" + sha256Hex(strings.Repeat("x", 100))
upstream := truncatingUpstream(t,
func(r *http.Request) bool { return strings.Contains(r.URL.Path, "/blobs/") },
"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: 100\r\n\r\nshort",
http.NotFound)
proxy, _, store, _ := setupTestProxy(t)
proxy.StreamArtifacts = true
useRealFetcher(t, proxy, upstream)
h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example", "", map[string]string{"ghcr": upstream.URL})
Add storage.cache_artifacts to serve artifacts without storing them (#387) * Add storage.passthrough to serve artifacts without storing them With storage.passthrough enabled the proxy streams every artifact from upstream to the client and never writes it to storage or the cache database. Metadata filtering, cooldown and the denylist work as before, so the proxy can sit behind another cache (e.g. an Artifactory remote) purely as a policy layer without holding a second copy of every package. Artifacts whose digest is known up front (OCI, Swift, Helm) are verified while streaming. Their responses are sent chunked and the connection is aborted on a mismatch, so a client never receives a tampered artifact as a complete response. Passthrough is rejected together with scanning, direct_serve and mirror_api, and the mirror command refuses to run with it, since all of them depend on stored artifacts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Address review: abort truncated streams, guard every cache read - serveArtifact aborts the response on any body read error, and when fewer bytes than the declared size were written, not only on a digest mismatch. A failed or short upstream body in passthrough mode no longer reaches the client as a complete 200. - Streamed upstream read failures are logged and counted as stream_failed upstream errors before the response is aborted. - checkCache reports a miss when artifact caching is off, so every cache read path, including Swift archive HEAD requests, ignores entries stored before the mode was enabled. Denylisted versions are still rejected. - Rename storage.passthrough to storage.cache_artifacts (default true, PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes. Tests cover an OCI blob shorter than its Content-Length and an npm tarball whose chunked response ends without the final chunk, both served through the HTTP handlers, plus a Swift HEAD request against a cached archive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Rename Proxy.Passthrough to StreamArtifacts Match the storage.cache_artifacts option: the field says what changes (artifacts are streamed instead of stored) and its zero value keeps the usual caching behaviour for every Proxy built without the server config, such as the mirror command and tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
srv := httptest.NewServer(h.Routes())
defer srv.Close()
requireIncompleteResponse(t, srv.URL+"/upstream/ghcr/owner/demo/blobs/"+digest)
if len(store.files) != 0 {
t.Errorf("streaming stored %d files, want none", len(store.files))
}
}
func TestStreamArtifactsNPMTarballWithoutFinalChunkIsIncomplete(t *testing.T) {
upstream := truncatingUpstream(t,
func(r *http.Request) bool { return strings.HasSuffix(r.URL.Path, ".tgz") },
"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nshort\r\n",
func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, `{"versions":{}}`) })
Add storage.cache_artifacts to serve artifacts without storing them (#387) * Add storage.passthrough to serve artifacts without storing them With storage.passthrough enabled the proxy streams every artifact from upstream to the client and never writes it to storage or the cache database. Metadata filtering, cooldown and the denylist work as before, so the proxy can sit behind another cache (e.g. an Artifactory remote) purely as a policy layer without holding a second copy of every package. Artifacts whose digest is known up front (OCI, Swift, Helm) are verified while streaming. Their responses are sent chunked and the connection is aborted on a mismatch, so a client never receives a tampered artifact as a complete response. Passthrough is rejected together with scanning, direct_serve and mirror_api, and the mirror command refuses to run with it, since all of them depend on stored artifacts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Address review: abort truncated streams, guard every cache read - serveArtifact aborts the response on any body read error, and when fewer bytes than the declared size were written, not only on a digest mismatch. A failed or short upstream body in passthrough mode no longer reaches the client as a complete 200. - Streamed upstream read failures are logged and counted as stream_failed upstream errors before the response is aborted. - checkCache reports a miss when artifact caching is off, so every cache read path, including Swift archive HEAD requests, ignores entries stored before the mode was enabled. Denylisted versions are still rejected. - Rename storage.passthrough to storage.cache_artifacts (default true, PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes. Tests cover an OCI blob shorter than its Content-Length and an npm tarball whose chunked response ends without the final chunk, both served through the HTTP handlers, plus a Swift HEAD request against a cached archive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Rename Proxy.Passthrough to StreamArtifacts Match the storage.cache_artifacts option: the field says what changes (artifacts are streamed instead of stored) and its zero value keeps the usual caching behaviour for every Proxy built without the server config, such as the mirror command and tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
proxy, _, store, _ := setupTestProxy(t)
proxy.StreamArtifacts = true
useRealFetcher(t, proxy, upstream)
srv := httptest.NewServer(NewNPMHandler(proxy, "http://proxy.test", upstream.URL).Routes())
defer srv.Close()
requireIncompleteResponse(t, srv.URL+"/leftpad/-/leftpad-1.0.0.tgz")
if len(store.files) != 0 {
t.Errorf("streaming stored %d files, want none", len(store.files))
}
}
func TestServeArtifactAbortsOnShortRead(t *testing.T) {
tests := []struct {
name string
reader io.Reader
size int64
}{
{"read error", io.MultiReader(strings.NewReader("short"), iotestErrReader{io.ErrUnexpectedEOF}), -1},
{"fewer bytes than the declared size", strings.NewReader("short"), 100},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &CacheResult{Reader: io.NopCloser(tt.reader), Artifact: artifacts.Artifact{Size: tt.size}}
defer func() {
if r := recover(); r != http.ErrAbortHandler {
t.Fatalf("recovered %v, want http.ErrAbortHandler", r)
}
}()
ServeArtifact(httptest.NewRecorder(), result)
})
}
}
type iotestErrReader struct{ err error }
func (r iotestErrReader) Read([]byte) (int, error) { return 0, r.err }
func TestStreamArtifactsSwiftArchiveHeadIgnoresCachedEntry(t *testing.T) {
archive := []byte("cached archive")
checksum := sha256.Sum256(archive)
var archiveRequests int
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, ".zip") {
archiveRequests++
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"id":"apple.example","version":"1.2.3","resources":[{"name":"source-archive","type":"application/zip","checksum":%q}]}`, hex.EncodeToString(checksum[:]))
}))
defer upstream.Close()
proxy, _, _, fetcher := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
fetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader(string(archive))),
Size: int64(len(archive)),
ContentType: "application/zip",
}
packagePURL, versionPURL := packageurl.MakeCacheStrings("swift", "apple/example", "1.2.3")
cached, err := proxy.getOrFetchArtifactFromURLWithCachePURLs(
context.Background(), "swift", "apple/example", "1.2.3", "example-1.2.3.zip",
packagePURL, versionPURL, upstream.URL+"/apple/example/1.2.3.zip", nil, hex.EncodeToString(checksum[:]), "",
Add storage.cache_artifacts to serve artifacts without storing them (#387) * Add storage.passthrough to serve artifacts without storing them With storage.passthrough enabled the proxy streams every artifact from upstream to the client and never writes it to storage or the cache database. Metadata filtering, cooldown and the denylist work as before, so the proxy can sit behind another cache (e.g. an Artifactory remote) purely as a policy layer without holding a second copy of every package. Artifacts whose digest is known up front (OCI, Swift, Helm) are verified while streaming. Their responses are sent chunked and the connection is aborted on a mismatch, so a client never receives a tampered artifact as a complete response. Passthrough is rejected together with scanning, direct_serve and mirror_api, and the mirror command refuses to run with it, since all of them depend on stored artifacts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Address review: abort truncated streams, guard every cache read - serveArtifact aborts the response on any body read error, and when fewer bytes than the declared size were written, not only on a digest mismatch. A failed or short upstream body in passthrough mode no longer reaches the client as a complete 200. - Streamed upstream read failures are logged and counted as stream_failed upstream errors before the response is aborted. - checkCache reports a miss when artifact caching is off, so every cache read path, including Swift archive HEAD requests, ignores entries stored before the mode was enabled. Denylisted versions are still rejected. - Rename storage.passthrough to storage.cache_artifacts (default true, PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes. Tests cover an OCI blob shorter than its Content-Length and an npm tarball whose chunked response ends without the final chunk, both served through the HTTP handlers, plus a Swift HEAD request against a cached archive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Rename Proxy.Passthrough to StreamArtifacts Match the storage.cache_artifacts option: the field says what changes (artifacts are streamed instead of stored) and its zero value keeps the usual caching behaviour for every Proxy built without the server config, such as the mirror command and tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
)
if err != nil {
t.Fatalf("seeding cache in normal mode: %v", err)
}
_ = cached.Reader.Close()
proxy.StreamArtifacts = true
fetcher.artifact = nil
fetcher.fetchErr = fetch.ErrNotFound
handler := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL).Routes()
w := httptest.NewRecorder()
handler.ServeHTTP(w, httptest.NewRequest(http.MethodHead, "/apple/example/1.2.3.zip", nil))
if w.Code == http.StatusOK {
t.Fatalf("HEAD served the cached archive (Content-Length %q) while streaming artifacts", w.Header().Get(headerContentLength))
}
if archiveRequests == 0 {
t.Error("HEAD did not ask the upstream archive")
}
w = httptest.NewRecorder()
handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/apple/example/1.2.3.zip", nil))
if w.Code == http.StatusOK {
t.Fatalf("GET served the cached archive while streaming artifacts")
}
}