Files
RenewCMS/test/update_assets_test.go
T

345 lines
12 KiB
Go

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)
}
}
})
}