package test import ( "RenewCMS/internal/domain/update" infraUpdate "RenewCMS/internal/infrastructure/update" "bytes" "errors" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "time" ) var errAssetDisk = errors.New("write failed: no space left on device") type failingWriteCloser struct { file *os.File allowed int } func (f *failingWriteCloser) Write(chunk []byte) (int, error) { if f.allowed <= 0 { return 0, errAssetDisk } if len(chunk) > f.allowed { written, _ := f.file.Write(chunk[:f.allowed]) f.allowed = 0 return written, errAssetDisk } f.allowed -= len(chunk) return f.file.Write(chunk) } func (f *failingWriteCloser) Close() error { return f.file.Close() } func assetOf(server *httptest.Server, name string, size int64) update.Asset { return update.Asset{Name: name, URL: server.URL + "/" + name, Size: size} } func fetchTarget(t *testing.T) string { t.Helper() return filepath.Join(t.TempDir(), "staged", "asset") } func noProgress(int64, int64) {} func TestHTTPAssetFetcherDownloads(t *testing.T) { payload := bytes.Repeat([]byte("renewcms"), 512) t.Run("writes the body and reports bytes done and total", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(payload) })) defer server.Close() fetcher := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme()) target := fetchTarget(t) var lastDone, lastTotal int64 err := fetcher.Fetch(assetOf(server, "binary", int64(len(payload))), target, func(done, total int64) { lastDone, lastTotal = done, total }) written, readErr := os.ReadFile(target) if err != nil || readErr != nil || !bytes.Equal(written, payload) { t.Fatalf("err = %v, read = %v", err, readErr) } if lastDone != int64(len(payload)) || lastTotal != int64(len(payload)) { t.Errorf("progress = %d of %d", lastDone, lastTotal) } }) t.Run("a truncated body is an error and leaves no file", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Length", "4096") _, _ = w.Write(payload[:2048]) w.(http.Flusher).Flush() panic(http.ErrAbortHandler) })) defer server.Close() target := fetchTarget(t) err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme()).Fetch(assetOf(server, "binary", 4096), target, noProgress) if err == nil { t.Error("a truncated download must fail") } if _, statErr := os.Stat(target); !os.IsNotExist(statErr) { t.Error("the partial file must be removed") } }) t.Run("a file over its size cap is refused", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Transfer-Encoding", "chunked") _, _ = w.Write(payload) })) defer server.Close() target := fetchTarget(t) err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme(), infraUpdate.WithSizeCap(100)).Fetch(assetOf(server, "binary", 100), target, noProgress) if err == nil { t.Error("the size cap must stop the download") } if _, statErr := os.Stat(target); !os.IsNotExist(statErr) { t.Error("the oversized file must be removed") } }) t.Run("the production caps are 64 KiB for checksums and 1 KiB for the signature", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(bytes.Repeat([]byte("a"), 2048)) })) defer server.Close() fetcher := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme()) if err := fetcher.Fetch(assetOf(server, "checksums.txt.sig", 2048), fetchTarget(t), noProgress); err == nil { t.Error("a 2 KiB signature must exceed the 1 KiB cap") } if err := fetcher.Fetch(assetOf(server, "checksums.txt", 2048), fetchTarget(t), noProgress); err != nil { t.Errorf("2 KiB of checksums is within the cap: %v", err) } }) t.Run("a stalled server hits the overall timeout", func(t *testing.T) { release := make(chan struct{}) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Length", "4096") _, _ = w.Write(payload[:10]) w.(http.Flusher).Flush() select { case <-release: case <-r.Context().Done(): } })) defer server.Close() defer close(release) started := time.Now() err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme(), infraUpdate.WithTimeout(150*time.Millisecond)).Fetch(assetOf(server, "binary", 4096), fetchTarget(t), noProgress) if err == nil || time.Since(started) > 3*time.Second { t.Errorf("err = %v after %v", err, time.Since(started)) } }) t.Run("a 404 or 500 is an error", func(t *testing.T) { for _, status := range []int{http.StatusNotFound, http.StatusInternalServerError} { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(status) })) target := fetchTarget(t) err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme()).Fetch(assetOf(server, "binary", 1), target, noProgress) server.Close() if err == nil { t.Errorf("status %d must fail", status) } if _, statErr := os.Stat(target); !os.IsNotExist(statErr) { t.Errorf("status %d left a file", status) } } }) t.Run("the disk filling during the write is an error and leaves no file", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(payload) })) defer server.Close() opener := func(path string) (io.WriteCloser, error) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return nil, err } file, err := os.Create(path) return &failingWriteCloser{file: file, allowed: 100}, err } target := fetchTarget(t) err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme(), infraUpdate.WithFileOpener(opener)).Fetch(assetOf(server, "binary", int64(len(payload))), target, noProgress) if !errors.Is(err, errAssetDisk) { t.Errorf("err = %v", err) } if _, statErr := os.Stat(target); !os.IsNotExist(statErr) { t.Error("the partial file must be removed") } }) } func TestHTTPAssetFetcherProductionPolicy(t *testing.T) { payload := []byte("signed release asset") t.Run("an http URL is refused", func(t *testing.T) { var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { requests.Add(1) _, _ = w.Write(payload) })) defer server.Close() err := infraUpdate.NewHTTPAssetFetcher(server.URL).Fetch(assetOf(server, "binary", int64(len(payload))), fetchTarget(t), noProgress) if err == nil || requests.Load() != 0 { t.Errorf("err = %v, requests = %d", err, requests.Load()) } }) t.Run("an https URL on another host is refused", func(t *testing.T) { var requests atomic.Int32 other := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { requests.Add(1) })) defer other.Close() allowed := httptest.NewTLSServer(http.NotFoundHandler()) defer allowed.Close() err := infraUpdate.NewHTTPAssetFetcher(allowed.URL, infraUpdate.WithTransport(other.Client().Transport)).Fetch(assetOf(other, "binary", 1), fetchTarget(t), noProgress) if err == nil || requests.Load() != 0 { t.Errorf("err = %v, requests = %d", err, requests.Load()) } }) t.Run("a redirect to another host is refused", func(t *testing.T) { var reached atomic.Int32 second := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { reached.Add(1) _, _ = w.Write(payload) })) defer second.Close() first := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, second.URL+"/binary", http.StatusFound) })) defer first.Close() err := infraUpdate.NewHTTPAssetFetcher(first.URL, infraUpdate.WithTransport(first.Client().Transport)).Fetch(assetOf(first, "binary", 1), fetchTarget(t), noProgress) if err == nil || reached.Load() != 0 { t.Errorf("err = %v, second server reached %d times", err, reached.Load()) } }) t.Run("a redirect to http is refused", func(t *testing.T) { var reached atomic.Int32 plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { reached.Add(1) _, _ = w.Write(payload) })) defer plain.Close() secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, strings.Replace(r.URL.Path, "/", plain.URL+"/", 1), http.StatusFound) })) defer secure.Close() err := infraUpdate.NewHTTPAssetFetcher(secure.URL, infraUpdate.WithTransport(secure.Client().Transport)).Fetch(assetOf(secure, "binary", 1), fetchTarget(t), noProgress) if err == nil || reached.Load() != 0 { t.Errorf("err = %v, plain server reached %d times", err, reached.Load()) } }) t.Run("an https URL on the allowed host is accepted, also after a same host redirect", func(t *testing.T) { server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/binary" { http.Redirect(w, r, "/final", http.StatusFound) return } _, _ = w.Write(payload) })) defer server.Close() target := fetchTarget(t) err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithTransport(server.Client().Transport)).Fetch(assetOf(server, "binary", int64(len(payload))), target, noProgress) written, _ := os.ReadFile(target) if err != nil || !bytes.Equal(written, payload) { t.Errorf("err = %v, body = %q", err, written) } }) } func TestHTTPAssetFetcherBoundaries(t *testing.T) { exact := bytes.Repeat([]byte("x"), 100) t.Run("a body exactly at the cap is accepted with a content length", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(exact) })) defer server.Close() err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme(), infraUpdate.WithSizeCap(100)).Fetch(assetOf(server, "binary", 100), fetchTarget(t), noProgress) if err != nil { t.Errorf("err = %v", err) } }) t.Run("a chunked body exactly at the cap is accepted", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Transfer-Encoding", "chunked") _, _ = w.Write(exact) })) defer server.Close() err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme(), infraUpdate.WithSizeCap(100)).Fetch(assetOf(server, "binary", 100), fetchTarget(t), noProgress) if err != nil { t.Errorf("err = %v", err) } }) t.Run("checksums over 64 KiB are refused without a custom cap", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(bytes.Repeat([]byte("a"), 64<<10+1)) })) defer server.Close() err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme()).Fetch(assetOf(server, "checksums.txt", 0), fetchTarget(t), noProgress) if err == nil { t.Error("65 KiB of checksums must exceed the cap") } }) t.Run("redirects are followed up to four hops and the fifth is refused", func(t *testing.T) { for _, hops := range []int{4, 5} { var served atomic.Int32 var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if int(served.Add(1)) <= hops { http.Redirect(w, r, server.URL+"/next", http.StatusFound) return } _, _ = w.Write(exact) })) err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme()).Fetch(assetOf(server, "binary", 100), fetchTarget(t), noProgress) server.Close() if hops == 4 && err != nil { t.Errorf("%d redirects: err = %v", hops, err) } if hops == 5 && err == nil { t.Errorf("%d redirects must be refused", hops) } } }) }