Files
pixa/internal/server/image_proxy_internal_test.go
T
sneak e06192b7a5
check / check (push) Canceled after 0s
Store a source the way a variant is stored, one row and one file per source URL (closes #308)
A source is now one source_content row and one file named by its
SourceKey, the SHA-256 of its host, path and query, made as the variant
key is. A store writes the file, then upserts the row, and removes the
file when the row cannot be written; an eviction deletes the row, then
the file, with no lock. source_metadata, the JSON files under
cache/metadata/, MetadataStorage, the content lock, the eviction
transaction and its test hook are gone, as is the reconciliation pass
that dropped a source row whose file is missing, so
TestReconciliationReadsAPageAtATime now counts 15 rows read, not 20.
Two source URLs serving the same bytes store them twice. The tests of
the removed sharing and lock go with them; the two eviction
cancellation tests no longer use the hook.

Model: opus-5-5
2026-10-21 13:50:43 +00:00

342 lines
9.5 KiB
Go

package server
import (
"bytes"
"context"
"database/sql"
"image"
"image/color"
"image/jpeg"
"image/png"
"io"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/globals"
"sneak.berlin/go/pixa/internal/handlers"
"sneak.berlin/go/pixa/internal/healthcheck"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/logger"
"sneak.berlin/go/pixa/internal/middleware"
)
// upstreamHost is the upstream host of the image URLs below. It is a
// documentation address (RFC 5737), which the fetcher's URL check accepts as
// public; the fetcher's dial function connects it to the test upstream server.
const upstreamHost = "192.0.2.10"
// TestImageProxyFlow requests images through pixa's router, handlers,
// upstream fetcher, image processor, disk cache and database, with only the
// upstream origin replaced by a local test server. The first request for a URL
// is fetched and converted; the second is served from the cache without
// another upstream request. The source and the converted image are then on
// disk, with their rows in the database.
func TestImageProxyFlow(t *testing.T) {
t.Parallel()
source := encodeTestPNG(t, 64, 48)
tests := []struct {
name string
sizeFormat string // the <size>.<format> part of the image URL
contentType string
decodeConfig func(io.Reader) (image.Config, error)
width, height int
}{
{"resize and convert to JPEG", "32x24.jpeg", "image/jpeg",
jpeg.DecodeConfig, 32, 24},
{"orig", "orig.orig", "image/png", png.DecodeConfig, 64, 48},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var upstreamRequests atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
upstreamRequests.Add(1)
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(source)
}))
t.Cleanup(upstream.Close)
s, db, stateDir := startImageProxy(t, upstream)
target := "/v1/image/" + upstreamHost + "/photo.png/" + tt.sizeFormat
first := getImage(t, s, target)
if got := first.Header().Get("X-Pixa-Cache"); got != "MISS" {
t.Errorf("first X-Pixa-Cache = %q, want MISS", got)
}
if got := first.Header().Get("Content-Type"); got != tt.contentType {
t.Errorf("Content-Type = %q, want %q", got, tt.contentType)
}
decoded, err := tt.decodeConfig(bytes.NewReader(first.Body.Bytes()))
if err != nil {
t.Fatalf("decoding the image: %v", err)
}
if decoded.Width != tt.width || decoded.Height != tt.height {
t.Errorf("image is %dx%d, want %dx%d",
decoded.Width, decoded.Height, tt.width, tt.height)
}
second := getImage(t, s, target)
if got := second.Header().Get("X-Pixa-Cache"); got != "HIT" {
t.Errorf("second X-Pixa-Cache = %q, want HIT", got)
}
if !bytes.Equal(second.Body.Bytes(), first.Body.Bytes()) {
t.Error("the second response is not the image the first served")
}
if got := upstreamRequests.Load(); got != 1 {
t.Errorf("upstream received %d requests, want 1", got)
}
checkSourceCached(t, db, stateDir, source)
checkVariantCached(t, db, stateDir, first.Body.Bytes(), tt.contentType)
})
}
}
// startImageProxy starts the components pixad's fx app builds, from a config
// with a fresh state directory and upstreamHost on the allowlist, and with an
// upstream fetcher that connects every upstream address to upstream. It
// returns the server with its routes, the database and the state directory.
func startImageProxy(
t *testing.T, upstream *httptest.Server,
) (*Server, *sql.DB, string) {
t.Helper()
stateDir := t.TempDir()
cfg := &config.Config{
SigningKey: testSigningKey,
StateDir: stateDir,
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
AllowlistHosts: []string{upstreamHost},
// The test upstream server has no TLS.
AllowHTTP: true,
// A limit of its own, so the cache does not size itself from the
// host's free disk space.
CacheMaxBytes: 64 << 20,
CacheMaxBytesExplicit: true,
UpstreamMaxResponseSize: config.DefaultUpstreamMaxResponseSize,
DownstreamTimeout: config.DefaultDownstreamTimeout,
}
fetcherCfg := httpfetcher.DefaultConfig()
fetcherCfg.AllowHTTP = true
fetcherCfg.DialContext = func(
ctx context.Context, network, _ string,
) (net.Conn, error) {
var dialer net.Dialer
return dialer.DialContext(ctx, network, upstream.Listener.Addr().String())
}
fetcher := httpfetcher.New(fetcherCfg)
var (
h *handlers.Handlers
mw *middleware.Middleware
db *database.Database
)
app := fxtest.New(t,
fx.Supply(cfg),
fx.Provide(
globals.New,
logger.New,
database.New,
healthcheck.New,
handlers.New,
middleware.New,
func() httpfetcher.Fetcher { return fetcher },
),
fx.Populate(&h, &mw, &db),
)
app.RequireStart()
t.Cleanup(app.RequireStop)
// Requests go straight to the router, as in newTestServer; the server's
// own start hook, which listens on a port, is left out.
s := &Server{config: cfg, mw: mw, h: h}
s.SetupRoutes()
return s, db.DB(), stateDir
}
// startImageProxyWithBlockedNetworks starts the components pixad's fx app
// builds, from a config with a fresh state directory and blockedNetworks as
// blocked_networks. Unlike startImageProxy it provides no upstream fetcher, so
// the handlers build the one pixad uses, which checks every address it
// connects to against the built-in blocked ranges and blocked_networks. It
// returns the server with its routes.
func startImageProxyWithBlockedNetworks(
t *testing.T, blockedNetworks []netip.Prefix,
) *Server {
t.Helper()
stateDir := t.TempDir()
cfg := &config.Config{
SigningKey: testSigningKey,
StateDir: stateDir,
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
BlockedNetworks: blockedNetworks,
// A fetch that gets past the checks and finds nothing at the address
// fails within this time at the latest.
UpstreamFetchTimeout: 2 * time.Second,
// With no connection slots, the fetch would fail before connecting.
UpstreamConnections: config.DefaultUpstreamConnections,
DownstreamTimeout: config.DefaultDownstreamTimeout,
}
var (
h *handlers.Handlers
mw *middleware.Middleware
)
app := fxtest.New(t,
fx.Supply(cfg),
fx.Provide(
globals.New,
logger.New,
database.New,
healthcheck.New,
handlers.New,
middleware.New,
),
fx.Populate(&h, &mw),
)
app.RequireStart()
t.Cleanup(app.RequireStop)
s := &Server{config: cfg, mw: mw, h: h}
s.SetupRoutes()
return s
}
// getImage sends a GET for target to s and fails unless it answers 200.
func getImage(t *testing.T, s *Server, target string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
s.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, target, nil))
t.Logf("GET %s: %d, X-Pixa-Cache %s",
target, rec.Code, rec.Header().Get("X-Pixa-Cache"))
if rec.Code != http.StatusOK {
t.Fatalf("GET %s status = %d, want %d; body %s",
target, rec.Code, http.StatusOK, rec.Body.String())
}
return rec
}
// checkSourceCached checks that source is stored in cache/sources under the
// source key of upstreamHost and /photo.png, and recorded under that key in
// source_content.
func checkSourceCached(t *testing.T, db *sql.DB, stateDir string, source []byte) {
t.Helper()
key := string(imagerequest.Source{Host: upstreamHost, Path: "/photo.png"}.Key())
checkFile(t, filepath.Join(stateDir, "cache", "sources", key[0:2], key[2:4], key),
source)
var rows int
err := db.QueryRowContext(t.Context(),
"SELECT COUNT(*) FROM source_content WHERE source_key = ?", key,
).Scan(&rows)
if err != nil || rows != 1 {
t.Errorf("source_content rows for the source = %d (error %v), want 1",
rows, err)
}
}
// checkVariantCached checks that the converted image served is recorded in
// variant_content with contentType, and stored under its cache key in
// cache/variants.
func checkVariantCached(
t *testing.T, db *sql.DB, stateDir string, served []byte, contentType string,
) {
t.Helper()
var cacheKey, storedType string
err := db.QueryRowContext(t.Context(),
"SELECT cache_key, content_type FROM variant_content",
).Scan(&cacheKey, &storedType)
if err != nil {
t.Fatalf("variant_content row: %v", err)
}
if storedType != contentType {
t.Errorf("variant_content content_type = %q, want %q",
storedType, contentType)
}
checkFile(t, filepath.Join(stateDir, "cache", "variants",
cacheKey[0:2], cacheKey[2:4], cacheKey), served)
}
// checkFile checks that the file at path holds want.
func checkFile(t *testing.T, path string, want []byte) {
t.Helper()
//nolint:gosec // G304: a path under the test's state directory
got, err := os.ReadFile(path)
if err != nil {
t.Errorf("reading %s: %v", path, err)
return
}
if !bytes.Equal(got, want) {
t.Errorf("%s holds %d bytes that are not the %d expected",
path, len(got), len(want))
}
}
// encodeTestPNG returns an opaque width x height PNG of one color.
func encodeTestPNG(t *testing.T, width, height int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := range height {
for x := range width {
img.Set(x, y, color.RGBA{R: 200, G: 40, B: 40, A: 255})
}
}
var buf bytes.Buffer
err := png.Encode(&buf, img)
if err != nil {
t.Fatalf("encoding the test PNG: %v", err)
}
return buf.Bytes()
}