345 lines
12 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|