Add the four settings the README documented but pixa lacked (closes #61) #142

Merged
clawbot merged 12 commits from issue-61-config-keys into next 2026-09-29 08:49:19 +02:00
4 changed files with 302 additions and 3 deletions
Showing only changes of commit 52b64c9197 - Show all commits
@@ -6,6 +6,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"git.eeqj.de/sneak/smartconfig" "git.eeqj.de/sneak/smartconfig"
) )
@@ -599,3 +600,217 @@ func TestEnsureStateDirFailsOnUncreatablePath(t *testing.T) {
t.Errorf("error %q does not name the offending key state_dir", err.Error()) t.Errorf("error %q does not name the offending key state_dir", err.Error())
} }
} }
// TestOmittedOriginTimeoutsAndSizeUseDefaults checks that the CORS
// origin, the upstream fetch timeout, the upstream response size limit
// and the downstream timeout default to the values pixa used before they
// could be configured.
func TestOmittedOriginTimeoutsAndSizeUseDefaults(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, signingKeyLine)
if err != nil {
t.Fatalf("minimal config should be valid, got error: %v", err)
}
if c.AccessControlAllowOrigin != "*" {
t.Errorf("AccessControlAllowOrigin = %q, want *", c.AccessControlAllowOrigin)
}
if c.UpstreamFetchTimeout != 30*time.Second {
t.Errorf("UpstreamFetchTimeout = %v, want 30s", c.UpstreamFetchTimeout)
}
if c.UpstreamMaxResponseSize != 50<<20 {
t.Errorf("UpstreamMaxResponseSize = %d, want %d (50 MiB)",
c.UpstreamMaxResponseSize, 50<<20)
}
if c.DownstreamTimeout != 60*time.Second {
t.Errorf("DownstreamTimeout = %v, want 60s", c.DownstreamTimeout)
}
}
// TestExplicitOriginTimeoutsAndSizeAreUsed checks that valid values for
// the CORS origin, the two timeouts and the response size limit are used
// as given.
func TestExplicitOriginTimeoutsAndSizeAreUsed(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, signingKeyLine+`
access_control_allow_origin: https://app.example.com
upstream_fetch_timeout: 10s
upstream_max_response_size: 1048576
downstream_timeout: 2m
`)
if err != nil {
t.Fatalf("valid config should load, got error: %v", err)
}
if c.AccessControlAllowOrigin != "https://app.example.com" {
t.Errorf("AccessControlAllowOrigin = %q, want https://app.example.com",
c.AccessControlAllowOrigin)
}
if c.UpstreamFetchTimeout != 10*time.Second {
t.Errorf("UpstreamFetchTimeout = %v, want 10s", c.UpstreamFetchTimeout)
}
if c.UpstreamMaxResponseSize != 1048576 {
t.Errorf("UpstreamMaxResponseSize = %d, want 1048576",
c.UpstreamMaxResponseSize)
}
if c.DownstreamTimeout != 2*time.Minute {
t.Errorf("DownstreamTimeout = %v, want 2m", c.DownstreamTimeout)
}
}
// TestOriginWithPortOrAnyOriginIsAccepted checks the other accepted forms
// of access_control_allow_origin: "*", and an origin with a port.
func TestOriginWithPortOrAnyOriginIsAccepted(t *testing.T) {
t.Parallel()
for _, origin := range []string{"*", "http://localhost:3000"} {
c, err := configFromYAML(t, signingKeyLine+
"access_control_allow_origin: \""+origin+"\"\n")
if err != nil {
t.Fatalf("origin %q should be accepted, got error: %v", origin, err)
}
if c.AccessControlAllowOrigin != origin {
t.Errorf("AccessControlAllowOrigin = %q, want %q",
c.AccessControlAllowOrigin, origin)
}
}
}
// invalidTimeoutCases are configs where upstream_fetch_timeout or
// downstream_timeout is not a positive Go duration string; each must
// abort startup naming the key and the value.
func invalidTimeoutCases() []abortCase {
return []abortCase{
{
name: "upstream_fetch_timeout not a duration",
yaml: signingKeyLine + "upstream_fetch_timeout: soon\n",
wantErrSubstrings: []string{keyUpstreamFetchTimeout, "soon"},
},
{
name: "upstream_fetch_timeout number without a unit",
yaml: signingKeyLine + "upstream_fetch_timeout: 45\n",
wantErrSubstrings: []string{keyUpstreamFetchTimeout, "45"},
},
{
name: "upstream_fetch_timeout zero",
yaml: signingKeyLine + "upstream_fetch_timeout: 0s\n",
wantErrSubstrings: []string{keyUpstreamFetchTimeout, "0s"},
},
{
name: "upstream_fetch_timeout negative",
yaml: signingKeyLine + "upstream_fetch_timeout: -5s\n",
wantErrSubstrings: []string{keyUpstreamFetchTimeout, "-5s"},
},
{
name: "upstream_fetch_timeout null",
yaml: signingKeyLine + "upstream_fetch_timeout: null\n",
wantErrSubstrings: []string{keyUpstreamFetchTimeout, nullValueText},
},
{
name: "downstream_timeout not a duration",
yaml: signingKeyLine + "downstream_timeout: 1 minute\n",
wantErrSubstrings: []string{keyDownstreamTimeout, "1 minute"},
},
{
name: "downstream_timeout zero",
yaml: signingKeyLine + "downstream_timeout: 0s\n",
wantErrSubstrings: []string{keyDownstreamTimeout, "0s"},
},
{
name: "downstream_timeout negative",
yaml: signingKeyLine + "downstream_timeout: -1m\n",
wantErrSubstrings: []string{keyDownstreamTimeout, "-1m"},
},
{
name: "downstream_timeout null",
yaml: signingKeyLine + "downstream_timeout:\n",
wantErrSubstrings: []string{keyDownstreamTimeout, nullValueText},
},
}
}
// invalidSizeAndOriginCases are configs where upstream_max_response_size
// is not a positive whole number of bytes, or access_control_allow_origin
// is neither "*" nor an origin; each must abort startup naming the key
// and the value.
func invalidSizeAndOriginCases() []abortCase {
return []abortCase{
{
name: "upstream_max_response_size with a unit",
yaml: signingKeyLine + "upstream_max_response_size: 50MB\n",
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "50MB"},
},
{
name: "upstream_max_response_size fractional",
yaml: signingKeyLine + "upstream_max_response_size: 1.5\n",
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "1.5"},
},
{
name: "upstream_max_response_size zero",
yaml: signingKeyLine + "upstream_max_response_size: 0\n",
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "0"},
},
{
name: "upstream_max_response_size negative",
yaml: signingKeyLine + "upstream_max_response_size: -1\n",
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "-1"},
},
{
name: "upstream_max_response_size null",
yaml: signingKeyLine + "upstream_max_response_size: null\n",
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, nullValueText},
},
{
name: "access_control_allow_origin bare hostname",
yaml: signingKeyLine + "access_control_allow_origin: example.com\n",
wantErrSubstrings: []string{
keyAccessControlAllowOrigin, "example.com",
},
},
{
name: "access_control_allow_origin with a path",
yaml: signingKeyLine +
"access_control_allow_origin: https://example.com/images\n",
wantErrSubstrings: []string{
keyAccessControlAllowOrigin, "https://example.com/images",
},
},
{
name: "access_control_allow_origin trailing slash",
yaml: signingKeyLine +
"access_control_allow_origin: https://example.com/\n",
wantErrSubstrings: []string{
keyAccessControlAllowOrigin, "https://example.com/",
},
},
{
name: "access_control_allow_origin empty",
yaml: signingKeyLine + "access_control_allow_origin: \"\"\n",
wantErrSubstrings: []string{keyAccessControlAllowOrigin},
},
{
name: "access_control_allow_origin null",
yaml: signingKeyLine + "access_control_allow_origin: null\n",
wantErrSubstrings: []string{keyAccessControlAllowOrigin, nullValueText},
},
}
}
// TestInvalidOriginTimeoutOrSizeAbortsStartup verifies the
// no-silent-fallback rule for the CORS origin, the two timeouts and the
// response size limit: a value that does not parse or is out of range
// aborts startup naming the key and the value.
func TestInvalidOriginTimeoutOrSizeAbortsStartup(t *testing.T) {
t.Parallel()
runAbortCases(t, append(invalidTimeoutCases(), invalidSizeAndOriginCases()...))
}
+33
View File
@@ -8,6 +8,7 @@ import (
"slices" "slices"
"strings" "strings"
"testing" "testing"
"time"
"sneak.berlin/go/pixa/internal/globals" "sneak.berlin/go/pixa/internal/globals"
"sneak.berlin/go/pixa/internal/logger" "sneak.berlin/go/pixa/internal/logger"
@@ -69,6 +70,10 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
t.Setenv("PIXA_CACHE_MAX_BYTES", "1024") t.Setenv("PIXA_CACHE_MAX_BYTES", "1024")
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24") t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24")
t.Setenv("PIXA_TRUSTED_PROXIES", "192.0.2.0/24") t.Setenv("PIXA_TRUSTED_PROXIES", "192.0.2.0/24")
t.Setenv("PIXA_ACCESS_CONTROL_ALLOW_ORIGIN", "https://app.example.com")
t.Setenv("PIXA_UPSTREAM_FETCH_TIMEOUT", "10s")
t.Setenv("PIXA_UPSTREAM_MAX_RESPONSE_SIZE", "1048576")
t.Setenv("PIXA_DOWNSTREAM_TIMEOUT", "2m")
c, err := newFromSmartConfig(nil) c, err := newFromSmartConfig(nil)
if err != nil { if err != nil {
@@ -92,6 +97,10 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
cacheMaxBytesExplicit: true, cacheMaxBytesExplicit: true,
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}, BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")},
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}, TrustedProxies: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
AccessControlAllowOrigin: "https://app.example.com",
UpstreamFetchTimeout: 10 * time.Second,
UpstreamMaxResponseSize: 1048576,
DownstreamTimeout: 2 * time.Minute,
} }
if !reflect.DeepEqual(*c, want) { if !reflect.DeepEqual(*c, want) {
@@ -280,6 +289,30 @@ func TestInvalidDebugFromEnvironmentAbortsStartup(t *testing.T) {
wantStartupError(t, err, "PIXA_DEBUG", "maybe") wantStartupError(t, err, "PIXA_DEBUG", "maybe")
} }
// TestInvalidOriginTimeoutOrSizeFromEnvironmentAbortsStartup checks that
// an invalid CORS origin, timeout or response size limit in its variable
// aborts startup naming the variable and the value.
func TestInvalidOriginTimeoutOrSizeFromEnvironmentAbortsStartup(t *testing.T) {
cases := []struct {
variable string
value string
}{
{"PIXA_ACCESS_CONTROL_ALLOW_ORIGIN", "example.com"},
{"PIXA_UPSTREAM_FETCH_TIMEOUT", "soon"},
{"PIXA_UPSTREAM_MAX_RESPONSE_SIZE", "50MB"},
{"PIXA_DOWNSTREAM_TIMEOUT", "0s"},
}
for _, tc := range cases {
t.Run(tc.variable, func(t *testing.T) {
t.Setenv(tc.variable, tc.value)
_, err := configFromYAML(t, signingKeyLine)
wantStartupError(t, err, tc.variable, tc.value)
})
}
}
// TestConfigFileAloneBehavesAsBefore checks that with no variables set // TestConfigFileAloneBehavesAsBefore checks that with no variables set
// (TestMain unsets them) the config file's values are used and omitted // (TestMain unsets them) the config file's values are used and omitted
// keys take their defaults. // keys take their defaults.
@@ -9,6 +9,53 @@ import (
"sneak.berlin/go/pixa/internal/config" "sneak.berlin/go/pixa/internal/config"
) )
// TestCORSAnswersWithConfiguredOrigin checks that the CORS middleware
// uses access_control_allow_origin: "*" lets any origin read responses,
// and a single origin lets that origin read them and no other.
func TestCORSAnswersWithConfiguredOrigin(t *testing.T) {
t.Parallel()
const appOrigin = "https://app.example.com"
cases := []struct {
configured string
requestOrigin string
want string
}{
{"*", "https://any.example.com", "*"},
{appOrigin, appOrigin, appOrigin},
{appOrigin, "https://other.example.com", ""},
}
testHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
for _, tc := range cases {
mw := &Middleware{
log: slog.Default(),
config: &config.Config{AccessControlAllowOrigin: tc.configured},
}
handler := mw.CORS()(testHandler)
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/v1/image/example.com/a.jpg/1x1.png", nil)
req.Header.Set("Origin", tc.requestOrigin)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
got := rec.Header().Get("Access-Control-Allow-Origin")
if got != tc.want {
t.Errorf("configured %q, request from %q: "+
"Access-Control-Allow-Origin = %q, want %q",
tc.configured, tc.requestOrigin, got, tc.want)
}
}
}
func TestSecurityHeaders(t *testing.T) { func TestSecurityHeaders(t *testing.T) {
t.Parallel() t.Parallel()
+7 -3
View File
@@ -11,11 +11,15 @@ import (
// carries every hardening timeout wired onto it, including the slowloris // carries every hardening timeout wired onto it, including the slowloris
// defense (ReadHeaderTimeout) and the keep-alive bound (IdleTimeout). This // defense (ReadHeaderTimeout) and the keep-alive bound (IdleTimeout). This
// guards against a field being defined but never set on the server, so // guards against a field being defined but never set on the server, so
// each assertion compares the server field to its constant. // each assertion compares the server field to its constant, or, for
// WriteTimeout, to downstream_timeout from the config.
func TestNewHTTPServerTimeouts(t *testing.T) { func TestNewHTTPServerTimeouts(t *testing.T) {
t.Parallel() t.Parallel()
s := &Server{config: &config.Config{Port: 8080}} s := &Server{config: &config.Config{
Port: 8080,
DownstreamTimeout: 45 * time.Second,
}}
srv := s.newHTTPServer() srv := s.newHTTPServer()
@@ -26,7 +30,7 @@ func TestNewHTTPServerTimeouts(t *testing.T) {
}{ }{
{"ReadTimeout", srv.ReadTimeout, HTTPReadTimeout}, {"ReadTimeout", srv.ReadTimeout, HTTPReadTimeout},
{"ReadHeaderTimeout", srv.ReadHeaderTimeout, HTTPReadHeaderTimeout}, {"ReadHeaderTimeout", srv.ReadHeaderTimeout, HTTPReadHeaderTimeout},
{"WriteTimeout", srv.WriteTimeout, HTTPWriteTimeout}, {"WriteTimeout", srv.WriteTimeout, 45 * time.Second},
{"IdleTimeout", srv.IdleTimeout, HTTPIdleTimeout}, {"IdleTimeout", srv.IdleTimeout, HTTPIdleTimeout},
} }