445 lines
14 KiB
Go
445 lines
14 KiB
Go
package test
|
|
|
|
import (
|
|
domain "RenewCMS/internal/domain/image"
|
|
"RenewCMS/internal/infrastructure/config"
|
|
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
|
"RenewCMS/internal/infrastructure/useCases"
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"hash/crc32"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
tooLargeBody = "The source image is too large to generate this variant."
|
|
pngSignatureLength = 8
|
|
pngChunkTypeLength = 4
|
|
pngHeaderLength = 13
|
|
gateTimeout = 5 * time.Second
|
|
settleDelay = 150 * time.Millisecond
|
|
)
|
|
|
|
func declaringPNG(t *testing.T, width int, height int) []byte {
|
|
t.Helper()
|
|
content := solidPNG(t, 1, 1)
|
|
headerStart := pngSignatureLength + pngChunkTypeLength
|
|
binary.BigEndian.PutUint32(content[headerStart+pngChunkTypeLength:], uint32(width))
|
|
binary.BigEndian.PutUint32(content[headerStart+pngChunkTypeLength+4:], uint32(height))
|
|
checksumStart := headerStart + pngChunkTypeLength + pngHeaderLength
|
|
binary.BigEndian.PutUint32(content[checksumStart:], crc32.ChecksumIEEE(content[headerStart:checksumStart]))
|
|
return content
|
|
}
|
|
|
|
func pixelLimitedEnv(t *testing.T, pixels int64) *variantEnv {
|
|
t.Helper()
|
|
env := newVariantEnvLimits(t, newCountingRenderer(), useCases.ServeVariantLimits{MaxSourceBytes: variantDefaultCap, MaxSourcePixels: pixels, MaxConcurrentRenders: variantDefaultRenders})
|
|
env.seedThumb()
|
|
return env
|
|
}
|
|
|
|
func TestImageVariantsSourcePixelCap(t *testing.T) {
|
|
rows := []struct {
|
|
width, height, status int
|
|
real bool
|
|
}{
|
|
{100, 100, 200, true},
|
|
{101, 100, 413, true},
|
|
{30000, 30000, 413, false},
|
|
}
|
|
for _, row := range rows {
|
|
t.Run("A source beyond IMAGE_VARIANT_MAX_SOURCE_PIXELS answers 413 without decoding it: "+strconv.Itoa(row.width)+"x"+strconv.Itoa(row.height), func(t *testing.T) {
|
|
env := pixelLimitedEnv(t, 10000)
|
|
content := declaringPNG(t, row.width, row.height)
|
|
if row.real {
|
|
content = solidPNG(t, row.width, row.height)
|
|
}
|
|
env.seedSource("wide.png", content)
|
|
|
|
recorder := env.get("/static/uploadedImages/wide.png?v=thumb", nil)
|
|
|
|
if recorder.Code != row.status {
|
|
t.Fatalf("status = %d", recorder.Code)
|
|
}
|
|
if row.status == 413 && (env.renderer.renderCount() != 0 || env.storage.putCount() != 0 || env.files.rowCount() != 0) {
|
|
t.Errorf("renders = %d, puts = %d, rows = %d", env.renderer.renderCount(), env.storage.putCount(), env.files.rowCount())
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("The pixel 413 body is plain text and nothing is generated", func(t *testing.T) {
|
|
env := pixelLimitedEnv(t, 10000)
|
|
env.seedSource("wide.png", declaringPNG(t, 30000, 30000))
|
|
|
|
recorder := env.get("/static/uploadedImages/wide.png?v=thumb", nil)
|
|
|
|
assertStatusAndBody(t, recorder, 413, tooLargeBody)
|
|
if !strings.HasPrefix(recorder.Header().Get("Content-Type"), "text/plain") || env.renderer.renderCount() != 0 || env.storage.putCount() != 0 {
|
|
t.Errorf("content type = %q, renders = %d", recorder.Header().Get("Content-Type"), env.renderer.renderCount())
|
|
}
|
|
})
|
|
|
|
t.Run("The pixel cap never affects the original", func(t *testing.T) {
|
|
env := pixelLimitedEnv(t, 10000)
|
|
content := declaringPNG(t, 30000, 30000)
|
|
env.seedSource("wide.png", content)
|
|
|
|
recorder := env.get("/static/uploadedImages/wide.png", nil)
|
|
|
|
if recorder.Code != 200 || !bytes.Equal(recorder.Body.Bytes(), content) {
|
|
t.Errorf("status = %d", recorder.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("The pixel cap never affects an SVG", func(t *testing.T) {
|
|
env := pixelLimitedEnv(t, 1)
|
|
svg := `<svg xmlns="http://www.w3.org/2000/svg" width="9000" height="9000"></svg>`
|
|
env.seedSource("e5f6.svg", []byte(svg))
|
|
|
|
recorder := env.get("/static/uploadedImages/e5f6.svg?v=thumb", nil)
|
|
|
|
if recorder.Code != 200 || recorder.Body.String() != svg || env.renderer.renderCount() != 0 {
|
|
t.Errorf("status = %d", recorder.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("A header that cannot be read answers 500 and caches nothing", func(t *testing.T) {
|
|
env := pixelLimitedEnv(t, 10000)
|
|
env.seedSource("bad.png", []byte("these bytes are not an image"))
|
|
|
|
recorder := env.get("/static/uploadedImages/bad.png?v=thumb", nil)
|
|
|
|
if recorder.Code != 500 || env.renderer.renderCount() != 0 || env.storage.putCount() != 0 || env.files.rowCount() != 0 {
|
|
t.Errorf("status = %d, renders = %d", recorder.Code, env.renderer.renderCount())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestImageVariantsKnownSource(t *testing.T) {
|
|
t.Run("An image record with that stored name is a source", func(t *testing.T) {
|
|
env := servingEnv(t)
|
|
|
|
if recorder := env.get(thumbURL, nil); recorder.Code != 200 {
|
|
t.Errorf("status = %d", recorder.Code)
|
|
}
|
|
})
|
|
|
|
for _, name := range []string{"gen-1.jpg", "orphan.png"} {
|
|
t.Run("A blob without an image record answers 404 and nothing is read: "+name, func(t *testing.T) {
|
|
env := servingEnv(t)
|
|
env.storage.seed(name, encodePNG(t, variantSolidImage(10, 10, variantRed)))
|
|
|
|
recorder := env.get("/static/uploadedImages/"+name+"?v=thumb", nil)
|
|
|
|
if recorder.Code != 404 || env.storage.openCountOf(name) != 0 || env.renderer.renderCount() != 0 || env.storage.putCount() != 0 || env.files.rowCount() != 0 {
|
|
t.Errorf("status = %d, opens = %d", recorder.Code, env.storage.openCountOf(name))
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("The record lookup comes after the parameter checks and before any storage read", func(t *testing.T) {
|
|
env := servingEnv(t)
|
|
env.storage.seed("orphan.png", []byte("orphan"))
|
|
|
|
unknown := env.get("/static/uploadedImages/orphan.png?v=banner", nil)
|
|
orphan := env.get("/static/uploadedImages/orphan.png?v=thumb", nil)
|
|
|
|
assertRequestError(t, unknown, "unknown_variant", unknownMessage, "banner")
|
|
if orphan.Code != 404 || env.storage.openCountOf("orphan.png") != 0 {
|
|
t.Errorf("status = %d, opens = %d", orphan.Code, env.storage.openCountOf("orphan.png"))
|
|
}
|
|
})
|
|
|
|
t.Run("The original of any stored blob is still served without a variant", func(t *testing.T) {
|
|
env := servingEnv(t)
|
|
env.storage.seed("gen-1.jpg", []byte("generated bytes"))
|
|
|
|
recorder := env.get("/static/uploadedImages/gen-1.jpg", nil)
|
|
|
|
if recorder.Code != 200 || recorder.Body.String() != "generated bytes" {
|
|
t.Errorf("status = %d", recorder.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("A failing record lookup answers 500 and reads nothing", func(t *testing.T) {
|
|
env := servingEnv(t)
|
|
env.images.fail.failNext("ExistsByStoredName")
|
|
|
|
recorder := env.get(thumbURL, nil)
|
|
|
|
if recorder.Code != 500 || env.storage.openCountOf(variantSourceName) != 0 || env.renderer.renderCount() != 0 {
|
|
t.Errorf("status = %d", recorder.Code)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestImageRepositoryExistsByStoredName(t *testing.T) {
|
|
repo := persistence.NewImageRepository(openActivityDatabase(t))
|
|
if _, err := repo.Create(domain.Image{Path: domain.PublicPath("a1b2.png"), Filename: "photo.png"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for name, want := range map[string]bool{"a1b2.png": true, "a1b2": false, "b2.png": false, "gen-1.jpg": false, "": false} {
|
|
found, err := repo.ExistsByStoredName(name)
|
|
if err != nil || found != want {
|
|
t.Errorf("%q: found = %v, err = %v", name, found, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
type renderGate struct {
|
|
mutex sync.Mutex
|
|
inFlight int
|
|
peak int
|
|
started chan struct{}
|
|
release chan struct{}
|
|
}
|
|
|
|
func newRenderGate() *renderGate {
|
|
return &renderGate{started: make(chan struct{}, 64), release: make(chan struct{}, 64)}
|
|
}
|
|
|
|
func (g *renderGate) hold() {
|
|
g.mutex.Lock()
|
|
g.inFlight++
|
|
g.peak = max(g.peak, g.inFlight)
|
|
g.mutex.Unlock()
|
|
g.started <- struct{}{}
|
|
<-g.release
|
|
g.mutex.Lock()
|
|
g.inFlight--
|
|
g.mutex.Unlock()
|
|
}
|
|
|
|
func (g *renderGate) running() int {
|
|
g.mutex.Lock()
|
|
defer g.mutex.Unlock()
|
|
return g.inFlight
|
|
}
|
|
|
|
func (g *renderGate) peakRunning() int {
|
|
g.mutex.Lock()
|
|
defer g.mutex.Unlock()
|
|
return g.peak
|
|
}
|
|
|
|
func (g *renderGate) awaitStarts(t *testing.T, count int) {
|
|
t.Helper()
|
|
for index := 0; index < count; index++ {
|
|
select {
|
|
case <-g.started:
|
|
case <-time.After(gateTimeout):
|
|
t.Fatalf("only %d renders started, want %d", index, count)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (g *renderGate) letRun(count int) {
|
|
for index := 0; index < count; index++ {
|
|
g.release <- struct{}{}
|
|
}
|
|
}
|
|
|
|
func concurrencyEnv(t *testing.T, slots int, sources int) (*variantEnv, *renderGate, []string) {
|
|
t.Helper()
|
|
renderer := newCountingRenderer()
|
|
env := newVariantEnvLimits(t, renderer, useCases.ServeVariantLimits{MaxSourceBytes: variantDefaultCap, MaxSourcePixels: variantDefaultPixels, MaxConcurrentRenders: slots})
|
|
env.seedThumb()
|
|
gate := newRenderGate()
|
|
urls := make([]string, 0, sources)
|
|
for index := 0; index < sources; index++ {
|
|
name := "src" + strconv.Itoa(index) + ".png"
|
|
env.seedSource(name, encodePNG(t, variantSolidImage(60, 40, variantRed)))
|
|
urls = append(urls, "/static/uploadedImages/"+name+"?v=thumb")
|
|
}
|
|
renderer.onRender = gate.hold
|
|
return env, gate, urls
|
|
}
|
|
|
|
func getAsync(env *variantEnv, ctx context.Context, url string, codes chan<- int) {
|
|
go func() {
|
|
request := httptest.NewRequest(http.MethodGet, url, nil).WithContext(ctx)
|
|
recorder := httptest.NewRecorder()
|
|
env.public.ServeHTTP(recorder, request)
|
|
codes <- recorder.Code
|
|
}()
|
|
}
|
|
|
|
func TestImageVariantsBoundedRenders(t *testing.T) {
|
|
t.Run("At most IMAGE_VARIANT_MAX_CONCURRENT_RENDERS renders run at once", func(t *testing.T) {
|
|
env, gate, urls := concurrencyEnv(t, 2, 5)
|
|
codes := make(chan int, len(urls))
|
|
for _, url := range urls {
|
|
getAsync(env, context.Background(), url, codes)
|
|
}
|
|
|
|
gate.awaitStarts(t, 2)
|
|
time.Sleep(settleDelay)
|
|
if gate.running() != 2 || env.renderer.renderCount() != 2 {
|
|
t.Fatalf("in flight = %d, renders = %d", gate.running(), env.renderer.renderCount())
|
|
}
|
|
gate.letRun(len(urls))
|
|
for range urls {
|
|
select {
|
|
case code := <-codes:
|
|
if code != 200 {
|
|
t.Errorf("status = %d", code)
|
|
}
|
|
case <-time.After(gateTimeout):
|
|
t.Fatal("a request never finished")
|
|
}
|
|
}
|
|
if gate.peakRunning() != 2 || env.renderer.renderCount() != 5 {
|
|
t.Errorf("peak = %d, renders = %d", gate.peakRunning(), env.renderer.renderCount())
|
|
}
|
|
})
|
|
|
|
t.Run("A cached file never takes a render slot", func(t *testing.T) {
|
|
env, gate, urls := concurrencyEnv(t, 1, 2)
|
|
gate.letRun(1)
|
|
warm := make(chan int, 1)
|
|
getAsync(env, context.Background(), urls[0], warm)
|
|
gate.awaitStarts(t, 1)
|
|
if code := <-warm; code != 200 {
|
|
t.Fatalf("warm-up status = %d", code)
|
|
}
|
|
blocked := make(chan int, 1)
|
|
getAsync(env, context.Background(), urls[1], blocked)
|
|
gate.awaitStarts(t, 1)
|
|
|
|
cached := make(chan int, 1)
|
|
getAsync(env, context.Background(), urls[0], cached)
|
|
|
|
select {
|
|
case code := <-cached:
|
|
if code != 200 {
|
|
t.Errorf("status = %d", code)
|
|
}
|
|
case <-time.After(gateTimeout):
|
|
t.Fatal("the cached request waited for a slot")
|
|
}
|
|
gate.letRun(1)
|
|
<-blocked
|
|
})
|
|
|
|
t.Run("A waiting request whose client has gone does not render", func(t *testing.T) {
|
|
env, gate, urls := concurrencyEnv(t, 1, 2)
|
|
holder := make(chan int, 1)
|
|
getAsync(env, context.Background(), urls[0], holder)
|
|
gate.awaitStarts(t, 1)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
waiter := make(chan int, 1)
|
|
getAsync(env, ctx, urls[1], waiter)
|
|
time.Sleep(settleDelay)
|
|
|
|
cancel()
|
|
|
|
select {
|
|
case <-waiter:
|
|
case <-time.After(gateTimeout):
|
|
t.Fatal("the waiting request kept waiting")
|
|
}
|
|
gate.letRun(1)
|
|
<-holder
|
|
if env.renderer.renderCount() != 1 || env.storage.putCount() != 1 || env.files.rowCount() != 1 {
|
|
t.Errorf("renders = %d, puts = %d, rows = %d", env.renderer.renderCount(), env.storage.putCount(), env.files.rowCount())
|
|
}
|
|
})
|
|
|
|
t.Run("Identical concurrent requests still share one render and one slot", func(t *testing.T) {
|
|
env, gate, urls := concurrencyEnv(t, 1, 1)
|
|
codes := make(chan int, 20)
|
|
for index := 0; index < 20; index++ {
|
|
getAsync(env, context.Background(), urls[0], codes)
|
|
}
|
|
|
|
gate.awaitStarts(t, 1)
|
|
time.Sleep(settleDelay)
|
|
gate.letRun(20)
|
|
var served atomic.Int32
|
|
for index := 0; index < 20; index++ {
|
|
if <-codes == 200 {
|
|
served.Add(1)
|
|
}
|
|
}
|
|
|
|
if served.Load() != 20 || env.renderer.renderCount() != 1 || gate.peakRunning() != 1 {
|
|
t.Errorf("served = %d, renders = %d, peak = %d", served.Load(), env.renderer.renderCount(), gate.peakRunning())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestImageVariantPixelAndConcurrencySettings(t *testing.T) {
|
|
rows := []struct {
|
|
name string
|
|
env string
|
|
value string
|
|
unset bool
|
|
want int64
|
|
failure bool
|
|
}{
|
|
{"pixels unset", "IMAGE_VARIANT_MAX_SOURCE_PIXELS", "", true, 50000000, false},
|
|
{"pixels 2048", "IMAGE_VARIANT_MAX_SOURCE_PIXELS", "2048", false, 2048, false},
|
|
{"pixels 0", "IMAGE_VARIANT_MAX_SOURCE_PIXELS", "0", false, 0, true},
|
|
{"pixels -5", "IMAGE_VARIANT_MAX_SOURCE_PIXELS", "-5", false, 0, true},
|
|
{"pixels many", "IMAGE_VARIANT_MAX_SOURCE_PIXELS", "many", false, 0, true},
|
|
{"renders unset", "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS", "", true, 4, false},
|
|
{"renders 1", "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS", "1", false, 1, false},
|
|
{"renders 0", "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS", "0", false, 0, true},
|
|
{"renders -1", "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS", "-1", false, 0, true},
|
|
{"renders many", "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS", "many", false, 0, true},
|
|
}
|
|
for _, row := range rows {
|
|
t.Run("The env var is loaded: "+row.name, func(t *testing.T) {
|
|
setBaseSettingsEnv(t)
|
|
if row.unset {
|
|
t.Setenv(row.env, "")
|
|
} else {
|
|
t.Setenv(row.env, row.value)
|
|
}
|
|
|
|
settings, err := config.NewSettingsFromEnv()
|
|
|
|
if row.failure {
|
|
if err == nil || !strings.Contains(err.Error(), row.env) {
|
|
t.Errorf("err = %v", err)
|
|
}
|
|
return
|
|
}
|
|
got := settings.ImageVariantMaxSourcePixels
|
|
if row.env == "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS" {
|
|
got = int64(settings.ImageVariantMaxConcurrentRenders)
|
|
}
|
|
if err != nil || got != row.want {
|
|
t.Errorf("value = %d, err = %v", got, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("Both defaults are listed in envDefaults, .env.example and docs/configuration.md", func(t *testing.T) {
|
|
defaults := between(readRepositoryFile(t, "cmd/server/main.go"), "var envDefaults = map[string]string{", "\n}")
|
|
required := between(readRepositoryFile(t, "cmd/server/main.go"), "var requiredEnvVars = []string{", "\n}")
|
|
for _, name := range []string{"EnvImageVariantMaxSourcePixels", "EnvImageVariantMaxConcurrentRenders"} {
|
|
if !strings.Contains(defaults, "config."+name) || strings.Contains(required, name) {
|
|
t.Errorf("%s is misplaced", name)
|
|
}
|
|
}
|
|
for _, document := range []string{".env.example", "docs/configuration.md"} {
|
|
content := readRepositoryFile(t, document)
|
|
for _, name := range []string{"IMAGE_VARIANT_MAX_SOURCE_PIXELS", "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS"} {
|
|
if !strings.Contains(content, name) {
|
|
t.Errorf("%s does not mention %s", document, name)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|