pkg-proxy/internal/handler/container_test.go
Andrew Nesbitt bdc246dc10
Fix container blob caching by passing auth token to fetcher (#44)
* Fix container blob caching by passing auth token to fetcher

The container handler was calling GetOrFetchArtifactFromURL without
authentication headers, causing Docker Hub to return 401. The fallback
proxyBlobWithAuth path had auth but bypassed the cache entirely.

Now passes the Bearer token through GetOrFetchArtifactFromURLWithHeaders
so blobs are both authenticated and cached.

Fixes git-pkgs/proxy#43

* Update registries to v0.4.0

Replace pre-release pseudo-version with the released v0.4.0 now that
git-pkgs/registries#13 has been merged.
2026-04-01 15:22:39 +01:00

244 lines
6.2 KiB
Go

package handler
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/registries/fetch"
)
func TestContainerHandler_parseBlobPath(t *testing.T) {
h := &ContainerHandler{}
tests := []struct {
path string
wantName string
wantDigest string
}{
{
path: "library/nginx/blobs/sha256:abc123def456",
wantName: "library/nginx",
wantDigest: "sha256:abc123def456",
},
{
path: "myorg/myrepo/blobs/sha256:0123456789abcdef",
wantName: "myorg/myrepo",
wantDigest: "sha256:0123456789abcdef",
},
{
path: "deep/nested/repo/name/blobs/sha256:fedcba9876543210",
wantName: "deep/nested/repo/name",
wantDigest: "sha256:fedcba9876543210",
},
{
path: "invalid/path",
wantName: "",
wantDigest: "",
},
{
path: "repo/blobs/md5:invalid",
wantName: "",
wantDigest: "",
},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
name, digest := h.parseBlobPath(tt.path)
if name != tt.wantName {
t.Errorf("parseBlobPath() name = %q, want %q", name, tt.wantName)
}
if digest != tt.wantDigest {
t.Errorf("parseBlobPath() digest = %q, want %q", digest, tt.wantDigest)
}
})
}
}
func TestContainerHandler_parseManifestPath(t *testing.T) {
h := &ContainerHandler{}
tests := []struct {
path string
wantName string
wantReference string
}{
{
path: "library/nginx/manifests/latest",
wantName: "library/nginx",
wantReference: "latest",
},
{
path: "myorg/myrepo/manifests/v1.0.0",
wantName: "myorg/myrepo",
wantReference: "v1.0.0",
},
{
path: "repo/manifests/sha256:abc123",
wantName: "repo",
wantReference: "sha256:abc123",
},
{
path: "invalid/path",
wantName: "",
},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
name, ref := h.parseManifestPath(tt.path)
if name != tt.wantName {
t.Errorf("parseManifestPath() name = %q, want %q", name, tt.wantName)
}
if ref != tt.wantReference {
t.Errorf("parseManifestPath() reference = %q, want %q", ref, tt.wantReference)
}
})
}
}
func TestContainerHandler_parseTagsListPath(t *testing.T) {
h := &ContainerHandler{}
tests := []struct {
path string
wantName string
}{
{
path: "library/nginx/tags/list",
wantName: "library/nginx",
},
{
path: "myorg/myrepo/tags/list",
wantName: "myorg/myrepo",
},
{
path: "invalid/path",
wantName: "",
},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
name := h.parseTagsListPath(tt.path)
if name != tt.wantName {
t.Errorf("parseTagsListPath() = %q, want %q", name, tt.wantName)
}
})
}
}
func TestContainerHandler_BlobDownload_CachesWithAuth(t *testing.T) {
// Set up a mock auth server that returns a token
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"token": "test-token-123"})
}))
defer authServer.Close()
// Set up mock fetcher that captures headers
var capturedHeaders http.Header
mf := &mockFetcherWithHeaders{
fetchFn: func(_ context.Context, _ string, headers http.Header) (*fetch.Artifact, error) {
capturedHeaders = headers
return &fetch.Artifact{
Body: io.NopCloser(bytes.NewReader([]byte("blob-content"))),
Size: 12,
ContentType: "application/octet-stream",
}, nil
},
}
dir := t.TempDir()
db, err := database.Create(dir + "/test.db")
if err != nil {
t.Fatalf("failed to create test database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
store := newMockStorage()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
proxy := &Proxy{
DB: db,
Storage: store,
Fetcher: mf,
Logger: logger,
HTTPClient: &http.Client{},
}
h := &ContainerHandler{
proxy: proxy,
registryURL: "https://registry-1.docker.io",
authURL: authServer.URL,
proxyURL: "http://localhost:8080",
}
handler := h.Routes()
req := httptest.NewRequest(http.MethodGet, "/library/nginx/blobs/sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("got status %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
// Verify auth header was passed to the fetcher
if capturedHeaders == nil {
t.Fatal("expected headers to be passed to fetcher, got nil")
}
auth := capturedHeaders.Get("Authorization")
if auth != "Bearer test-token-123" {
t.Errorf("Authorization = %q, want %q", auth, "Bearer test-token-123")
}
// Verify response headers
if got := w.Header().Get("Docker-Content-Digest"); got != "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd" {
t.Errorf("Docker-Content-Digest = %q, want digest", got)
}
}
// mockFetcherWithHeaders captures headers passed to FetchWithHeaders.
type mockFetcherWithHeaders struct {
fetchFn func(ctx context.Context, url string, headers http.Header) (*fetch.Artifact, error)
}
func (f *mockFetcherWithHeaders) Fetch(ctx context.Context, url string) (*fetch.Artifact, error) {
return f.FetchWithHeaders(ctx, url, nil)
}
func (f *mockFetcherWithHeaders) FetchWithHeaders(ctx context.Context, url string, headers http.Header) (*fetch.Artifact, error) {
return f.fetchFn(ctx, url, headers)
}
func (f *mockFetcherWithHeaders) Head(_ context.Context, _ string) (int64, string, error) {
return 0, "", nil
}
func TestContainerHandler_Routes_VersionCheck(t *testing.T) {
h := NewContainerHandler(nil, "http://localhost:8080")
handler := h.Routes()
if handler == nil {
t.Fatal("Routes() returned nil")
}
// Test /v2/ version check endpoint
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("version check: got status %d, want %d", w.Code, http.StatusOK)
}
if got := w.Header().Get("Docker-Distribution-Api-Version"); got != "registry/2.0" {
t.Errorf("Docker-Distribution-Api-Version = %q, want %q", got, "registry/2.0")
}
}