Update golangci-lint to v2.12.2 with canonical config #54

Open
clawbot wants to merge 3 commits from golangci-v2.12.2 into main
56 changed files with 2971 additions and 2163 deletions

View File

@@ -1,117 +1,34 @@
version: "2" version: "2"
# Config schema uses the golangci-lint v2 layout (settings live under
# linters.settings, not top-level linters-settings) so that the
# thresholds below are actually applied by golangci-lint >= v2.
run: run:
go: "1.24" timeout: 5m
tests: false modules-download-mode: readonly
linters: linters:
enable: default: all
# Additional linters requested disable:
- testifylint # Checks usage of github.com/stretchr/testify # Genuinely incompatible with project patterns
- usetesting # usetesting is an analyzer that detects using os.Setenv instead of t.Setenv since Go 1.17 - exhaustruct # Requires all struct fields
# - tagliatelle # Disabled: we need snake_case for external API compatibility - depguard # Dependency allow/block lists
- nlreturn # nlreturn checks for a new line before return and branch statements - godot # Requires comments to end with periods
- nilnil # Checks that there is no simultaneous return of nil error and an invalid value - wsl # Deprecated, replaced by wsl_v5
- nestif # Reports deeply nested if statements - wrapcheck # Too verbose for internal packages
- mnd # An analyzer to detect magic numbers - varnamelen # Short names like db, id are idiomatic Go
- lll # Reports long lines settings:
- intrange # intrange is a linter to find places where for loops could make use of an integer range lll:
- gochecknoglobals # Check that no global variables exist line-length: 88
funlen:
# Default/existing linters that are commonly useful lines: 80
- govet statements: 50
- errcheck cyclop:
- staticcheck max-complexity: 15
- unused dupl:
- ineffassign threshold: 100
- misspell
- revive
- gosec
- unconvert
- unparam
linters-settings:
lll:
line-length: 120
nestif:
min-complexity: 4
nlreturn:
block-size: 2
revive:
rules:
- name: var-naming
arguments:
- []
- []
- "upperCaseConst=true"
tagliatelle:
case:
rules:
json: snake
yaml: snake
xml: snake
bson: snake
testifylint:
enable-all: true
usetesting: {}
issues: issues:
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0
exclude-rules:
# Exclude unused parameter warnings for cobra command signatures
- text: "parameter '(args|cmd)' seems to be unused"
linters:
- revive
# Allow ALL_CAPS constant names
- text: "don't use ALL_CAPS in Go names"
linters:
- revive
# Allow snake_case JSON tags for external API compatibility
- path: "internal/types/ris.go"
linters:
- tagliatelle
# Allow snake_case JSON tags for database models
- path: "internal/database/models.go"
linters:
- tagliatelle
# Allow generic package name for types that define data structures
- path: "internal/types/"
text: "avoid meaningless package names"
linters:
- revive
# Allow globals in the globals package (by design)
- path: "internal/globals/"
linters:
- gochecknoglobals
# Allow globals in main (Version/Buildarch set by ldflags)
- path: "cmd/"
linters:
- gochecknoglobals
# Allow blank imports for driver registration
- text: "blank-imports"
linters:
- revive
# Allow unused fx.Lifecycle parameters (required by fx signature)
- text: "parameter 'lc' seems to be unused"
linters:
- revive
# Allow unused context parameters in fx hooks
- text: "parameter 'ctx' seems to be unused"
linters:
- revive

View File

@@ -1,6 +1,6 @@
# Lint stage # Lint stage
# golangci/golangci-lint:v2.10.1-alpine, 2026-02-17 # golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
FROM golangci/golangci-lint:v2.10.1-alpine@sha256:33bc6b6156d4c7da87175f187090019769903d04dd408833b83083ed214b0ddf AS lint FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 AS lint
RUN apk add --no-cache make build-base vips-dev libheif-dev pkgconfig RUN apk add --no-cache make build-base vips-dev libheif-dev pkgconfig

10
TODO.md
View File

@@ -24,6 +24,16 @@ fill up
# Completed Steps # Completed Steps
- 2026-08-07 update golangci-lint to v2.12.2 with the canonical
`.golangci.yml` (v2 schema, `default: all` minus six disabled
linters, `lll` 88, tests included): bumped the pinned
`golangci/golangci-lint:v2.12.2-alpine` image in `Dockerfile` and the
release-archive sha256 pins in `script/bootstrap`; fixed all 747
findings the stricter config surfaced (notably `paralleltest`,
`wsl_v5`, `goconst`, `lll`, `noinlineerr`, `err113`, `errcheck`,
`testpackage` — white-box test files renamed to
`*_internal_test.go`); three `//nolint:tagliatelle` directives keep
the snake_case JSON wire/disk formats unchanged; `make check` green
- 2026-08-07 validate configuration on startup, fail fast on bad - 2026-08-07 validate configuration on startup, fail fast on bad
config (closes #52): a config value that is set but unparseable or config (closes #52): a config value that is set but unparseable or
invalid aborts startup naming the key and value (defaults apply only invalid aborts startup naming the key and value (defaults apply only

View File

@@ -30,7 +30,8 @@ func main() {
rootCmd.Flags().StringVarP(&configPath, "config", "c", "", "path to config file") rootCmd.Flags().StringVarP(&configPath, "config", "c", "", "path to config file")
if err := rootCmd.Execute(); err != nil { err := rootCmd.Execute()
if err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
} }

View File

@@ -10,7 +10,8 @@ import (
type HostAllowList struct { type HostAllowList struct {
// exactHosts contains hosts that must match exactly (e.g., "cdn.example.com") // exactHosts contains hosts that must match exactly (e.g., "cdn.example.com")
exactHosts map[string]struct{} exactHosts map[string]struct{}
// suffixHosts contains domain suffixes to match (e.g., ".example.com" matches "cdn.example.com") // suffixHosts contains domain suffixes to match
// (e.g., ".example.com" matches "cdn.example.com")
suffixHosts []string suffixHosts []string
} }

View File

@@ -7,104 +7,37 @@ import (
"sneak.berlin/go/pixa/internal/allowlist" "sneak.berlin/go/pixa/internal/allowlist"
) )
func TestHostAllowList_IsAllowed(t *testing.T) { const (
tests := []struct { testExactHost = "cdn.example.com"
name string testImageURL = "https://cdn.example.com/image.jpg"
patterns []string testSuffix = ".example.com"
testURL string )
want bool
}{ type isAllowedCase struct {
{ name string
name: "exact match", patterns []string
patterns: []string{"cdn.example.com"}, testURL string
testURL: "https://cdn.example.com/image.jpg", want bool
want: true, }
},
{ func runIsAllowedCases(t *testing.T, tests []isAllowedCase) {
name: "exact match case insensitive", t.Helper()
patterns: []string{"CDN.Example.COM"},
testURL: "https://cdn.example.com/image.jpg",
want: true,
},
{
name: "exact match not found",
patterns: []string{"cdn.example.com"},
testURL: "https://other.example.com/image.jpg",
want: false,
},
{
name: "suffix match",
patterns: []string{".example.com"},
testURL: "https://cdn.example.com/image.jpg",
want: true,
},
{
name: "suffix match deep subdomain",
patterns: []string{".example.com"},
testURL: "https://cdn.images.example.com/image.jpg",
want: true,
},
{
name: "suffix match apex domain",
patterns: []string{".example.com"},
testURL: "https://example.com/image.jpg",
want: true,
},
{
name: "suffix match not found",
patterns: []string{".example.com"},
testURL: "https://notexample.com/image.jpg",
want: false,
},
{
name: "suffix match partial not allowed",
patterns: []string{".example.com"},
testURL: "https://fakeexample.com/image.jpg",
want: false,
},
{
name: "multiple patterns",
patterns: []string{"cdn.example.com", ".images.org", "static.test.net"},
testURL: "https://photos.images.org/image.jpg",
want: true,
},
{
name: "empty allow list",
patterns: []string{},
testURL: "https://cdn.example.com/image.jpg",
want: false,
},
{
name: "nil url",
patterns: []string{"cdn.example.com"},
testURL: "",
want: false,
},
{
name: "url with port",
patterns: []string{"cdn.example.com"},
testURL: "https://cdn.example.com:443/image.jpg",
want: true,
},
{
name: "whitespace in patterns",
patterns: []string{" cdn.example.com ", " .other.com "},
testURL: "https://cdn.example.com/image.jpg",
want: true,
},
}
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel()
w := allowlist.New(tt.patterns) w := allowlist.New(tt.patterns)
var u *url.URL var u *url.URL
if tt.testURL != "" { if tt.testURL != "" {
var err error parsed, err := url.Parse(tt.testURL)
u, err = url.Parse(tt.testURL)
if err != nil { if err != nil {
t.Fatalf("failed to parse test URL: %v", err) t.Fatalf("failed to parse test URL: %v", err)
} }
u = parsed
} }
got := w.IsAllowed(u) got := w.IsAllowed(u)
@@ -115,7 +48,101 @@ func TestHostAllowList_IsAllowed(t *testing.T) {
} }
} }
func TestHostAllowList_IsAllowed_ExactMatch(t *testing.T) {
t.Parallel()
runIsAllowedCases(t, []isAllowedCase{
{
name: "exact match",
patterns: []string{testExactHost},
testURL: testImageURL,
want: true,
},
{
name: "exact match case insensitive",
patterns: []string{"CDN.Example.COM"},
testURL: testImageURL,
want: true,
},
{
name: "exact match not found",
patterns: []string{testExactHost},
testURL: "https://other.example.com/image.jpg",
want: false,
},
{
name: "multiple patterns",
patterns: []string{testExactHost, ".images.org", "static.test.net"},
testURL: "https://photos.images.org/image.jpg",
want: true,
},
{
name: "empty allow list",
patterns: []string{},
testURL: testImageURL,
want: false,
},
{
name: "nil url",
patterns: []string{testExactHost},
testURL: "",
want: false,
},
{
name: "url with port",
patterns: []string{testExactHost},
testURL: "https://cdn.example.com:443/image.jpg",
want: true,
},
{
name: "whitespace in patterns",
patterns: []string{" cdn.example.com ", " .other.com "},
testURL: testImageURL,
want: true,
},
})
}
func TestHostAllowList_IsAllowed_SuffixMatch(t *testing.T) {
t.Parallel()
runIsAllowedCases(t, []isAllowedCase{
{
name: "suffix match",
patterns: []string{testSuffix},
testURL: testImageURL,
want: true,
},
{
name: "suffix match deep subdomain",
patterns: []string{testSuffix},
testURL: "https://cdn.images.example.com/image.jpg",
want: true,
},
{
name: "suffix match apex domain",
patterns: []string{testSuffix},
testURL: "https://example.com/image.jpg",
want: true,
},
{
name: "suffix match not found",
patterns: []string{testSuffix},
testURL: "https://notexample.com/image.jpg",
want: false,
},
{
name: "suffix match partial not allowed",
patterns: []string{testSuffix},
testURL: "https://fakeexample.com/image.jpg",
want: false,
},
})
}
func TestHostAllowList_IsEmpty(t *testing.T) { func TestHostAllowList_IsEmpty(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
name string name string
patterns []string patterns []string
@@ -145,6 +172,8 @@ func TestHostAllowList_IsEmpty(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel()
w := allowlist.New(tt.patterns) w := allowlist.New(tt.patterns)
if got := w.IsEmpty(); got != tt.want { if got := w.IsEmpty(); got != tt.want {
t.Errorf("IsEmpty() = %v, want %v", got, tt.want) t.Errorf("IsEmpty() = %v, want %v", got, tt.want)
@@ -154,6 +183,8 @@ func TestHostAllowList_IsEmpty(t *testing.T) {
} }
func TestHostAllowList_Count(t *testing.T) { func TestHostAllowList_Count(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
name string name string
patterns []string patterns []string
@@ -183,6 +214,8 @@ func TestHostAllowList_Count(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel()
w := allowlist.New(tt.patterns) w := allowlist.New(tt.patterns)
if got := w.Count(); got != tt.want { if got := w.Count(); got != tt.want {
t.Errorf("Count() = %v, want %v", got, tt.want) t.Errorf("Count() = %v, want %v", got, tt.want)

View File

@@ -2,6 +2,7 @@
package config package config
import ( import (
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"math" "math"
@@ -25,9 +26,54 @@ const (
DefaultUpstreamConnectionsPerHost = 20 DefaultUpstreamConnectionsPerHost = 20
) )
// Configuration key names.
const (
keyDebug = "debug"
keyMaintenanceMode = "maintenance_mode"
keyPort = "port"
keyStateDir = "state_dir"
keySentryDSN = "sentry_dsn"
keyDBURL = "db_url"
keyMetrics = "metrics"
keyMetricsUsername = "metrics.username"
keyMetricsPassword = "metrics.password"
keySigningKey = "signing_key"
keyAllowlistHosts = "allowlist_hosts"
keyAllowHTTP = "allow_http"
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
)
// Static validation errors. Each use site attaches the offending key
// and value by wrapping these with fmt.Errorf and %w.
var (
errValueRequired = errors.New("a value is required")
errValueEmpty = errors.New("value must not be empty")
errUnknownConfigKeys = errors.New("unknown config keys")
errNotAString = errors.New("not a string")
errNotAnInteger = errors.New("not an integer")
errNotABoolean = errors.New("not a boolean")
errNotAStringList = errors.New("not a list of strings")
errNotAMetricsMap = errors.New("not a map of metrics settings")
errEmptyListEntry = errors.New("list contains an empty entry")
errEmptyEntry = errors.New("contains an empty entry")
errNotAValidURL = errors.New("not a valid URL")
errPortOutOfRange = errors.New("outside the valid port range")
errTooFewConnections = errors.New("must be at least 1")
errValueTooShort = errors.New("value too short")
errMustBeSetTogether = errors.New("must be set together")
errValueNull = errors.New(
"value is null; omit the key entirely to use the default")
errValuesNull = errors.New(
"value is null; omit a key entirely to use its default")
errNotBareHostname = errors.New(
"must be a bare hostname without scheme, path, or whitespace")
errNoHostnameLabels = errors.New("contains no hostname labels")
)
// Params defines dependencies for Config. // Params defines dependencies for Config.
type Params struct { type Params struct {
fx.In fx.In
Globals *globals.Globals Globals *globals.Globals
Logger *logger.Logger Logger *logger.Logger
} }
@@ -69,7 +115,8 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
return nil, err return nil, err
} }
if err := c.ensureStateDirWritable(); err != nil { err = c.ensureStateDirWritable()
if err != nil {
return nil, err return nil, err
} }
@@ -87,11 +134,13 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
// to omitted keys, never to invalid explicit values. // to omitted keys, never to invalid explicit values.
func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) { func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
if sc != nil { if sc != nil {
if err := validateKnownKeys(sc); err != nil { err := validateKnownKeys(sc)
if err != nil {
return nil, err return nil, err
} }
if err := validateAllowlistHostsValue(sc); err != nil { err = validateAllowlistHostsValue(sc)
if err != nil {
return nil, err return nil, err
} }
} }
@@ -99,30 +148,30 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
loader := &strictLoader{sc: sc} loader := &strictLoader{sc: sc}
c := &Config{ c := &Config{
Debug: loader.boolVal("debug", false), Debug: loader.boolVal(keyDebug, false),
MaintenanceMode: loader.boolVal("maintenance_mode", false), MaintenanceMode: loader.boolVal(keyMaintenanceMode, false),
Port: loader.intVal("port", DefaultPort), Port: loader.intVal(keyPort, DefaultPort),
StateDir: loader.stringVal("state_dir", DefaultStateDir), StateDir: loader.stringVal(keyStateDir, DefaultStateDir),
SentryDSN: loader.stringVal("sentry_dsn", ""), SentryDSN: loader.stringVal(keySentryDSN, ""),
MetricsUsername: loader.stringVal("metrics.username", ""), MetricsUsername: loader.stringVal(keyMetricsUsername, ""),
MetricsPassword: loader.stringVal("metrics.password", ""), MetricsPassword: loader.stringVal(keyMetricsPassword, ""),
SigningKey: loader.stringVal("signing_key", ""), SigningKey: loader.stringVal(keySigningKey, ""),
AllowlistHosts: getStringSlice(sc, "allowlist_hosts"), AllowlistHosts: getStringSlice(sc),
AllowHTTP: loader.boolVal("allow_http", false), AllowHTTP: loader.boolVal(keyAllowHTTP, false),
UpstreamConnectionsPerHost: loader.intVal( UpstreamConnectionsPerHost: loader.intVal(
"upstream_connections_per_host", DefaultUpstreamConnectionsPerHost), keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
} }
// Build DBURL from StateDir if not explicitly set. The derived URL // Build DBURL from StateDir if not explicitly set. The derived URL
// is a default: it applies only when db_url is omitted, never to an // is a default: it applies only when db_url is omitted, never to an
// explicitly empty value. // explicitly empty value.
c.DBURL = loader.stringVal("db_url", "") c.DBURL = loader.stringVal(keyDBURL, "")
if c.DBURL == "" && loader.err == nil { if c.DBURL == "" && loader.err == nil {
if sc != nil { if sc != nil {
if _, present := sc.Get("db_url"); present { if _, present := sc.Get(keyDBURL); present {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"config key %q: value must not be empty; omit the key to derive it from state_dir", "config key %q: %w; omit the key to derive it from state_dir",
"db_url") keyDBURL, errValueEmpty)
} }
} }
@@ -133,7 +182,8 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
return nil, loader.err return nil, loader.err
} }
if err := c.validate(); err != nil { err := c.validate()
if err != nil {
return nil, err return nil, err
} }
@@ -162,23 +212,22 @@ func validateKnownKeys(sc *smartconfig.Config) error {
continue continue
} }
if key == "metrics" { if key == keyMetrics {
metricsMap, ok := value.(map[string]interface{}) metricsMap, ok := value.(map[string]any)
if !ok { if !ok {
return fmt.Errorf( return fmt.Errorf("config key %q: value %v is %w",
"config key %q: value %v is not a map of metrics settings", keyMetrics, value, errNotAMetricsMap)
"metrics", value)
} }
for subkey, subvalue := range metricsMap { for subkey, subvalue := range metricsMap {
if subkey != "username" && subkey != "password" { if subkey != "username" && subkey != "password" {
unknown = append(unknown, "metrics."+subkey) unknown = append(unknown, keyMetrics+"."+subkey)
continue continue
} }
if subvalue == nil { if subvalue == nil {
nullKeys = append(nullKeys, "metrics."+subkey) nullKeys = append(nullKeys, keyMetrics+"."+subkey)
} }
} }
} }
@@ -187,7 +236,7 @@ func validateKnownKeys(sc *smartconfig.Config) error {
if len(unknown) > 0 { if len(unknown) > 0 {
sort.Strings(unknown) sort.Strings(unknown)
return fmt.Errorf("unknown config keys: %s", strings.Join(unknown, ", ")) return fmt.Errorf("%w: %s", errUnknownConfigKeys, strings.Join(unknown, ", "))
} }
if len(nullKeys) > 0 { if len(nullKeys) > 0 {
@@ -197,9 +246,8 @@ func validateKnownKeys(sc *smartconfig.Config) error {
return errNullConfigValue(nullKeys[0]) return errNullConfigValue(nullKeys[0])
} }
return fmt.Errorf( return fmt.Errorf("config keys %s: %w",
"config keys %s: value is null; omit a key entirely to use its default", strings.Join(nullKeys, ", "), errValuesNull)
strings.Join(nullKeys, ", "))
} }
return nil return nil
@@ -209,17 +257,16 @@ func validateKnownKeys(sc *smartconfig.Config) error {
// null (including the bare "key:" form and the "~" alias). Silently // null (including the bare "key:" form and the "~" alias). Silently
// applying the default would mask a truncated or typo'd config entry. // applying the default would mask a truncated or typo'd config entry.
func errNullConfigValue(key string) error { func errNullConfigValue(key string) error {
return fmt.Errorf( return fmt.Errorf("config key %q: %w", key, errValueNull)
"config key %q: value is null; omit the key entirely to use the default", key)
} }
// isKnownConfigKey reports whether key is a permitted top-level // isKnownConfigKey reports whether key is a permitted top-level
// configuration key. // configuration key.
func isKnownConfigKey(key string) bool { func isKnownConfigKey(key string) bool {
switch key { switch key {
case "debug", "maintenance_mode", "port", "state_dir", "sentry_dsn", case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
"db_url", "metrics", "signing_key", "allowlist_hosts", "allow_http", keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
"upstream_connections_per_host", "env": keyUpstreamConnectionsPerHost, "env":
return true return true
} }
@@ -232,28 +279,30 @@ func isKnownConfigKey(key string) bool {
func (c *Config) ensureStateDirWritable() error { func (c *Config) ensureStateDirWritable() error {
const stateDirPerms = 0o750 const stateDirPerms = 0o750
if err := os.MkdirAll(c.StateDir, stateDirPerms); err != nil { err := os.MkdirAll(c.StateDir, stateDirPerms)
if err != nil {
return fmt.Errorf("config key %q: cannot create directory %q: %w", return fmt.Errorf("config key %q: cannot create directory %q: %w",
"state_dir", c.StateDir, err) keyStateDir, c.StateDir, err)
} }
probe, err := os.CreateTemp(c.StateDir, ".startup-write-probe-*") probe, err := os.CreateTemp(c.StateDir, ".startup-write-probe-*")
if err != nil { if err != nil {
return fmt.Errorf("config key %q: directory %q is not writable: %w", return fmt.Errorf("config key %q: directory %q is not writable: %w",
"state_dir", c.StateDir, err) keyStateDir, c.StateDir, err)
} }
probePath := probe.Name() probePath := probe.Name()
if err := probe.Close(); err != nil { err = probe.Close()
if err != nil {
return fmt.Errorf("config key %q: cannot close probe file %q: %w", return fmt.Errorf("config key %q: cannot close probe file %q: %w",
"state_dir", probePath, err) keyStateDir, probePath, err)
} }
//nolint:gosec // G703: probePath comes from os.CreateTemp inside the just-validated StateDir err = os.Remove(probePath)
if err := os.Remove(probePath); err != nil { if err != nil {
return fmt.Errorf("config key %q: cannot remove probe file %q: %w", return fmt.Errorf("config key %q: cannot remove probe file %q: %w",
"state_dir", probePath, err) keyStateDir, probePath, err)
} }
return nil return nil
@@ -264,33 +313,35 @@ func (c *Config) ensureStateDirWritable() error {
func (c *Config) validate() error { func (c *Config) validate() error {
// The signing key value is never echoed in error messages. // The signing key value is never echoed in error messages.
if c.SigningKey == "" { if c.SigningKey == "" {
return fmt.Errorf("config key %q: a value is required", "signing_key") return fmt.Errorf("config key %q: %w", keySigningKey, errValueRequired)
} }
// Minimum key length for security (32 bytes = 256 bits) // Minimum key length for security (32 bytes = 256 bits)
const minKeyLength = 32 const minKeyLength = 32
if len(c.SigningKey) < minKeyLength { if len(c.SigningKey) < minKeyLength {
return fmt.Errorf("config key %q: value must be at least %d characters, got %d", return fmt.Errorf("config key %q: %w: must be at least %d characters, got %d",
"signing_key", minKeyLength, len(c.SigningKey)) keySigningKey, errValueTooShort, minKeyLength, len(c.SigningKey))
} }
const maxPort = 65535 const maxPort = 65535
if c.Port < 1 || c.Port > maxPort { if c.Port < 1 || c.Port > maxPort {
return fmt.Errorf("config key %q: value %d is outside the valid port range 1-%d", return fmt.Errorf("config key %q: value %d is %w 1-%d",
"port", c.Port, maxPort) keyPort, c.Port, errPortOutOfRange, maxPort)
} }
if c.UpstreamConnectionsPerHost < 1 { if c.UpstreamConnectionsPerHost < 1 {
return fmt.Errorf("config key %q: value %d must be at least 1", return fmt.Errorf("config key %q: value %d %w",
"upstream_connections_per_host", c.UpstreamConnectionsPerHost) keyUpstreamConnectionsPerHost, c.UpstreamConnectionsPerHost,
errTooFewConnections)
} }
if c.StateDir == "" { if c.StateDir == "" {
return fmt.Errorf("config key %q: value must not be empty", "state_dir") return fmt.Errorf("config key %q: %w", keyStateDir, errValueEmpty)
} }
for _, host := range c.AllowlistHosts { for _, host := range c.AllowlistHosts {
if err := validateAllowlistHost(host); err != nil { err := validateAllowlistHost(host)
if err != nil {
return err return err
} }
} }
@@ -298,14 +349,14 @@ func (c *Config) validate() error {
if c.SentryDSN != "" { if c.SentryDSN != "" {
parsed, err := url.Parse(c.SentryDSN) parsed, err := url.Parse(c.SentryDSN)
if err != nil || parsed.Scheme == "" || parsed.Host == "" { if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("config key %q: value %q is not a valid URL", return fmt.Errorf("config key %q: value %q is %w",
"sentry_dsn", c.SentryDSN) keySentryDSN, c.SentryDSN, errNotAValidURL)
} }
} }
if (c.MetricsUsername == "") != (c.MetricsPassword == "") { if (c.MetricsUsername == "") != (c.MetricsPassword == "") {
return fmt.Errorf("config keys %q and %q must be set together", return fmt.Errorf("config keys %q and %q %w",
"metrics.username", "metrics.password") keyMetricsUsername, keyMetricsPassword, errMustBeSetTogether)
} }
return nil return nil
@@ -320,21 +371,20 @@ func (c *Config) validate() error {
// disable URL signing. // disable URL signing.
func validateAllowlistHost(host string) error { func validateAllowlistHost(host string) error {
if strings.Contains(host, "://") || strings.ContainsAny(host, "/ \t") { if strings.Contains(host, "://") || strings.ContainsAny(host, "/ \t") {
return fmt.Errorf( return fmt.Errorf("config key %q: entry %q %w",
"config key %q: entry %q must be a bare hostname without scheme, path, or whitespace", keyAllowlistHosts, host, errNotBareHostname)
"allowlist_hosts", host)
} }
if strings.Trim(host, ".") == "" { if strings.Trim(host, ".") == "" {
return fmt.Errorf( return fmt.Errorf("config key %q: entry %q %w",
"config key %q: entry %q contains no hostname labels", keyAllowlistHosts, host, errNoHostnameLabels)
"allowlist_hosts", host)
} }
return nil return nil
} }
// loadConfigFile loads configuration from PIXA_CONFIG_PATH env var or standard locations. // loadConfigFile loads configuration from the PIXA_CONFIG_PATH env var
// or standard locations.
func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, error) { func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, error) {
// Check for explicit config path from environment // Check for explicit config path from environment
if envPath := os.Getenv("PIXA_CONFIG_PATH"); envPath != "" { if envPath := os.Getenv("PIXA_CONFIG_PATH"); envPath != "" {
@@ -360,8 +410,9 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
for _, path := range configPaths { for _, path := range configPaths {
cleanPath := filepath.Clean(path) cleanPath := filepath.Clean(path)
//nolint:gosec // G703: paths are hardcoded config locations
if _, statErr := os.Stat(cleanPath); statErr == nil { _, statErr := os.Stat(cleanPath)
if statErr == nil {
// A config file that exists but does not parse is a fatal // A config file that exists but does not parse is a fatal
// startup error, never something to skip over. // startup error, never something to skip over.
sc, err := smartconfig.NewFromConfigPath(path) sc, err := smartconfig.NewFromConfigPath(path)
@@ -444,8 +495,8 @@ func getString(sc *smartconfig.Config, key, defaultVal string) (string, error) {
str, ok := raw.(string) str, ok := raw.(string)
if !ok { if !ok {
return "", fmt.Errorf("config key %q: value %v (%T) is not a string", return "", fmt.Errorf("config key %q: value %v (%T) is %w",
key, raw, raw) key, raw, raw, errNotAString)
} }
return str, nil return str, nil
@@ -475,20 +526,22 @@ func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) {
return int(val), nil return int(val), nil
case float64: case float64:
if val != math.Trunc(val) { if val != math.Trunc(val) {
return 0, fmt.Errorf("config key %q: value %v is not an integer", key, val) return 0, fmt.Errorf("config key %q: value %v is %w",
key, val, errNotAnInteger)
} }
return int(val), nil return int(val), nil
case string: case string:
parsed, err := strconv.Atoi(strings.TrimSpace(val)) parsed, err := strconv.Atoi(strings.TrimSpace(val))
if err != nil { if err != nil {
return 0, fmt.Errorf("config key %q: value %q is not an integer", key, val) return 0, fmt.Errorf("config key %q: value %q is %w",
key, val, errNotAnInteger)
} }
return parsed, nil return parsed, nil
default: default:
return 0, fmt.Errorf("config key %q: value %v (%T) is not an integer", return 0, fmt.Errorf("config key %q: value %v (%T) is %w",
key, raw, raw) key, raw, raw, errNotAnInteger)
} }
} }
@@ -516,13 +569,14 @@ func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error)
case string: case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(val)) parsed, err := strconv.ParseBool(strings.TrimSpace(val))
if err != nil { if err != nil {
return false, fmt.Errorf("config key %q: value %q is not a boolean", key, val) return false, fmt.Errorf("config key %q: value %q is %w",
key, val, errNotABoolean)
} }
return parsed, nil return parsed, nil
default: default:
return false, fmt.Errorf("config key %q: value %v (%T) is not a boolean", return false, fmt.Errorf("config key %q: value %v (%T) is %w",
key, raw, raw) key, raw, raw, errNotABoolean)
} }
} }
@@ -532,28 +586,27 @@ func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error)
// (or a comma-separated string), a non-string entry, or an empty entry // (or a comma-separated string), a non-string entry, or an empty entry
// is an error, never silently skipped. // is an error, never silently skipped.
func validateAllowlistHostsValue(sc *smartconfig.Config) error { func validateAllowlistHostsValue(sc *smartconfig.Config) error {
const key = "allowlist_hosts" raw, ok := sc.Get(keyAllowlistHosts)
raw, ok := sc.Get(key)
if !ok { if !ok {
return nil return nil
} }
if raw == nil { if raw == nil {
return errNullConfigValue(key) return errNullConfigValue(keyAllowlistHosts)
} }
switch val := raw.(type) { switch val := raw.(type) {
case []interface{}: case []any:
for _, item := range val { for _, item := range val {
str, ok := item.(string) str, ok := item.(string)
if !ok { if !ok {
return fmt.Errorf( return fmt.Errorf("config key %q: list entry %v (%T) is %w",
"config key %q: list entry %v (%T) is not a string", key, item, item) keyAllowlistHosts, item, item, errNotAString)
} }
if strings.TrimSpace(str) == "" { if strings.TrimSpace(str) == "" {
return fmt.Errorf("config key %q: list contains an empty entry", key) return fmt.Errorf("config key %q: %w",
keyAllowlistHosts, errEmptyListEntry)
} }
} }
case string: case string:
@@ -561,36 +614,36 @@ func validateAllowlistHostsValue(sc *smartconfig.Config) error {
return nil return nil
} }
for _, part := range strings.Split(val, ",") { for part := range strings.SplitSeq(val, ",") {
if strings.TrimSpace(part) == "" { if strings.TrimSpace(part) == "" {
return fmt.Errorf( return fmt.Errorf("config key %q: value %q %w",
"config key %q: value %q contains an empty entry", key, val) keyAllowlistHosts, val, errEmptyEntry)
} }
} }
default: default:
return fmt.Errorf("config key %q: value %v (%T) is not a list of strings", return fmt.Errorf("config key %q: value %v (%T) is %w",
key, raw, raw) keyAllowlistHosts, raw, raw, errNotAStringList)
} }
return nil return nil
} }
// getStringSlice returns the list of strings for key, or nil if the key // getStringSlice returns the allowlist_hosts list of strings, or nil if
// is omitted. It accepts a YAML list of strings or a comma-separated // the key is omitted. It accepts a YAML list of strings or a
// string (backwards compatibility). Malformed entries are rejected // comma-separated string (backwards compatibility). Malformed entries
// beforehand by validateAllowlistHostsValue. // are rejected beforehand by validateAllowlistHostsValue.
func getStringSlice(sc *smartconfig.Config, key string) []string { func getStringSlice(sc *smartconfig.Config) []string {
if sc == nil { if sc == nil {
return nil return nil
} }
val, ok := sc.Get(key) val, ok := sc.Get(keyAllowlistHosts)
if !ok || val == nil { if !ok || val == nil {
return nil return nil
} }
// Handle YAML list format // Handle YAML list format
if slice, ok := val.([]interface{}); ok { if slice, ok := val.([]any); ok {
result := make([]string, 0, len(slice)) result := make([]string, 0, len(slice))
for _, item := range slice { for _, item := range slice {
if str, ok := item.(string); ok { if str, ok := item.(string); ok {

View File

@@ -0,0 +1,98 @@
package config
import (
"os"
"path/filepath"
"testing"
"git.eeqj.de/sneak/smartconfig"
)
// writeTestConfig writes yamlContent to a temp config file and returns
// the file path.
func writeTestConfig(t *testing.T, yamlContent string) string {
t.Helper()
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yml")
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
if err != nil {
t.Fatalf("failed to write test config: %v", err)
}
return configPath
}
// checkAllowlistHosts loads the config at configPath and asserts that
// getStringSlice returns the three expected hosts.
func checkAllowlistHosts(t *testing.T, configPath string) {
t.Helper()
sc, err := loadTestConfig(configPath)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
hosts := getStringSlice(sc)
if len(hosts) != 3 {
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
}
expected := []string{"static.sneak.cloud", "sneak.berlin", testHostS3}
for i, want := range expected {
if i >= len(hosts) {
t.Errorf("missing host at index %d: want %q", i, want)
continue
}
if hosts[i] != want {
t.Errorf("host[%d] = %q, want %q", i, hosts[i], want)
}
}
}
func TestGetStringSlice_YAMLList(t *testing.T) {
t.Parallel()
yamlContent := `
allowlist_hosts:
- static.sneak.cloud
- sneak.berlin
- s3.sneak.cloud
`
checkAllowlistHosts(t, writeTestConfig(t, yamlContent))
}
func TestGetStringSlice_CommaSeparated(t *testing.T) {
t.Parallel()
// Backwards compatibility with comma-separated string values.
yamlContent := `allowlist_hosts: "static.sneak.cloud, sneak.berlin, s3.sneak.cloud"`
checkAllowlistHosts(t, writeTestConfig(t, yamlContent))
}
func TestGetStringSlice_Empty(t *testing.T) {
t.Parallel()
configPath := writeTestConfig(t, `port: 8080`)
sc, err := loadTestConfig(configPath)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
hosts := getStringSlice(sc)
if len(hosts) != 0 {
t.Errorf("expected nil or empty slice, got %v", hosts)
}
}
// loadTestConfig is a helper to load a config file for testing.
func loadTestConfig(path string) (*smartconfig.Config, error) {
return smartconfig.NewFromConfigPath(path)
}

View File

@@ -1,113 +0,0 @@
package config
import (
"os"
"path/filepath"
"testing"
"git.eeqj.de/sneak/smartconfig"
)
func TestGetStringSlice_YAMLList(t *testing.T) {
// Create a temp config file with YAML list format
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yml")
yamlContent := `
allowlist_hosts:
- static.sneak.cloud
- sneak.berlin
- s3.sneak.cloud
`
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
if err != nil {
t.Fatalf("failed to write test config: %v", err)
}
// Load config using smartconfig
sc, err := loadTestConfig(configPath)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
// Test that getStringSlice correctly parses YAML list
hosts := getStringSlice(sc, "allowlist_hosts")
if len(hosts) != 3 {
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
}
expected := []string{"static.sneak.cloud", "sneak.berlin", "s3.sneak.cloud"}
for i, want := range expected {
if i >= len(hosts) {
t.Errorf("missing host at index %d: want %q", i, want)
continue
}
if hosts[i] != want {
t.Errorf("host[%d] = %q, want %q", i, hosts[i], want)
}
}
}
func TestGetStringSlice_CommaSeparated(t *testing.T) {
// Test backwards compatibility with comma-separated string
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yml")
yamlContent := `allowlist_hosts: "static.sneak.cloud, sneak.berlin, s3.sneak.cloud"`
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
if err != nil {
t.Fatalf("failed to write test config: %v", err)
}
sc, err := loadTestConfig(configPath)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
hosts := getStringSlice(sc, "allowlist_hosts")
if len(hosts) != 3 {
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
}
expected := []string{"static.sneak.cloud", "sneak.berlin", "s3.sneak.cloud"}
for i, want := range expected {
if i >= len(hosts) {
t.Errorf("missing host at index %d: want %q", i, want)
continue
}
if hosts[i] != want {
t.Errorf("host[%d] = %q, want %q", i, hosts[i], want)
}
}
}
func TestGetStringSlice_Empty(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yml")
yamlContent := `port: 8080`
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
if err != nil {
t.Fatalf("failed to write test config: %v", err)
}
sc, err := loadTestConfig(configPath)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
hosts := getStringSlice(sc, "allowlist_hosts")
if hosts != nil && len(hosts) != 0 {
t.Errorf("expected nil or empty slice, got %v", hosts)
}
}
// loadTestConfig is a helper to load a config file for testing
func loadTestConfig(path string) (*smartconfig.Config, error) {
return smartconfig.NewFromConfigPath(path)
}

View File

@@ -1,7 +1,6 @@
package config package config
import ( import (
"io"
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
@@ -15,6 +14,26 @@ import (
// minimum length requirement in validate(). // minimum length requirement in validate().
const validTestSigningKey = "0123456789abcdef0123456789abcdef" const validTestSigningKey = "0123456789abcdef0123456789abcdef"
// signingKeyLine is a valid signing_key config line used as the base of
// test config files.
const signingKeyLine = "signing_key: " + validTestSigningKey + "\n"
// testHostS3 is an allowlist host entry used across the config tests.
const testHostS3 = "s3.sneak.cloud"
// nullValueText is the substring that error messages about explicitly
// null config values must contain.
const nullValueText = "null"
// abortCase describes a config file that must abort startup with an
// error mentioning every string in wantErrSubstrings.
type abortCase struct {
name string
yaml string
// wantErrSubstrings must all appear in the error message.
wantErrSubstrings []string
}
// configFromYAML writes yamlContent to a temporary config file, loads it // configFromYAML writes yamlContent to a temporary config file, loads it
// via smartconfig, and constructs a Config from it using the same code // via smartconfig, and constructs a Config from it using the same code
// path the server uses at startup. // path the server uses at startup.
@@ -24,7 +43,8 @@ func configFromYAML(t *testing.T, yamlContent string) (*Config, error) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yml") configPath := filepath.Join(tmpDir, "config.yml")
if err := os.WriteFile(configPath, []byte(yamlContent), 0o600); err != nil { err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
if err != nil {
t.Fatalf("failed to write test config: %v", err) t.Fatalf("failed to write test config: %v", err)
} }
@@ -37,7 +57,9 @@ func configFromYAML(t *testing.T, yamlContent string) (*Config, error) {
} }
func TestOmittedValuesUseDefaults(t *testing.T) { func TestOmittedValuesUseDefaults(t *testing.T) {
c, err := configFromYAML(t, "signing_key: "+validTestSigningKey+"\n") t.Parallel()
c, err := configFromYAML(t, signingKeyLine)
if err != nil { if err != nil {
t.Fatalf("minimal config should be valid, got error: %v", err) t.Fatalf("minimal config should be valid, got error: %v", err)
} }
@@ -78,6 +100,8 @@ func TestOmittedValuesUseDefaults(t *testing.T) {
} }
func TestExplicitValidValuesAreUsed(t *testing.T) { func TestExplicitValidValuesAreUsed(t *testing.T) {
t.Parallel()
yamlContent := ` yamlContent := `
port: 9090 port: 9090
debug: true debug: true
@@ -118,9 +142,10 @@ metrics:
t.Errorf("DBURL = %q, want explicit value", c.DBURL) t.Errorf("DBURL = %q, want explicit value", c.DBURL)
} }
if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != "s3.sneak.cloud" || if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != testHostS3 ||
c.AllowlistHosts[1] != ".example.com" { c.AllowlistHosts[1] != ".example.com" {
t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud .example.com]", c.AllowlistHosts) t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud .example.com]",
c.AllowlistHosts)
} }
if c.UpstreamConnectionsPerHost != 5 { if c.UpstreamConnectionsPerHost != 5 {
@@ -138,8 +163,10 @@ metrics:
} }
func TestCommaSeparatedAllowlistStillSupported(t *testing.T) { func TestCommaSeparatedAllowlistStillSupported(t *testing.T) {
yamlContent := `signing_key: ` + validTestSigningKey + ` t.Parallel()
allowlist_hosts: "s3.sneak.cloud, sneak.berlin"
yamlContent := signingKeyLine +
`allowlist_hosts: "s3.sneak.cloud, sneak.berlin"
` `
c, err := configFromYAML(t, yamlContent) c, err := configFromYAML(t, yamlContent)
@@ -147,9 +174,160 @@ allowlist_hosts: "s3.sneak.cloud, sneak.berlin"
t.Fatalf("comma-separated allowlist should load, got error: %v", err) t.Fatalf("comma-separated allowlist should load, got error: %v", err)
} }
if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != "s3.sneak.cloud" || if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != testHostS3 ||
c.AllowlistHosts[1] != "sneak.berlin" { c.AllowlistHosts[1] != "sneak.berlin" {
t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud sneak.berlin]", c.AllowlistHosts) t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud sneak.berlin]",
c.AllowlistHosts)
}
}
// runAbortCases asserts that each case's config aborts startup with an
// error message mentioning every expected substring.
func runAbortCases(t *testing.T, cases []abortCase) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, tc.yaml)
if err == nil {
t.Fatalf("config with %s must abort startup, got config: %+v", tc.name, c)
}
t.Logf("got expected error: %v", err)
for _, want := range tc.wantErrSubstrings {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not mention %q", err.Error(), want)
}
}
})
}
}
// invalidScalarValueCases are configs where a scalar key is explicitly
// set to an unparseable or out-of-range value; each must abort startup
// naming the offending key, never silently fall back to the default.
func invalidScalarValueCases() []abortCase {
return []abortCase{
{
name: "port not a number",
yaml: signingKeyLine + "port: banana\n",
wantErrSubstrings: []string{keyPort, "banana"},
},
{
name: "port zero",
yaml: signingKeyLine + "port: 0\n",
wantErrSubstrings: []string{keyPort, "0"},
},
{
name: "port above 65535",
yaml: signingKeyLine + "port: 99999\n",
wantErrSubstrings: []string{keyPort, "99999"},
},
{
name: "port fractional",
yaml: signingKeyLine + "port: 8080.5\n",
wantErrSubstrings: []string{keyPort, "8080.5"},
},
{
name: "debug not a bool",
yaml: signingKeyLine + "debug: notabool\n",
wantErrSubstrings: []string{keyDebug, "notabool"},
},
{
name: "maintenance_mode not a bool",
yaml: signingKeyLine + "maintenance_mode: sometimes\n",
wantErrSubstrings: []string{keyMaintenanceMode, "sometimes"},
},
{
name: "allow_http numeric",
yaml: signingKeyLine + "allow_http: 2\n",
wantErrSubstrings: []string{keyAllowHTTP, "2"},
},
{
name: "upstream_connections_per_host zero",
yaml: signingKeyLine + "upstream_connections_per_host: 0\n",
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, "0"},
},
{
name: "upstream_connections_per_host negative",
yaml: signingKeyLine + "upstream_connections_per_host: -3\n",
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, "-3"},
},
{
name: "upstream_connections_per_host not a number",
yaml: signingKeyLine + "upstream_connections_per_host: many\n",
wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, "many"},
},
}
}
// invalidHostAndCredentialCases are configs where allowlist_hosts,
// signing_key, state_dir, sentry_dsn, or metrics is explicitly set to
// an invalid value; each must abort startup naming the offending key.
func invalidHostAndCredentialCases() []abortCase {
return []abortCase{
{
name: "allowlist host with scheme",
yaml: signingKeyLine + "allowlist_hosts:\n - https://example.com\n",
wantErrSubstrings: []string{
keyAllowlistHosts, "https://example.com",
},
},
{
name: "allowlist host with path",
yaml: signingKeyLine + "allowlist_hosts:\n - example.com/images\n",
wantErrSubstrings: []string{
keyAllowlistHosts, "example.com/images",
},
},
{
name: "allowlist host with whitespace",
yaml: signingKeyLine + "allowlist_hosts:\n - \"exa mple.com\"\n",
wantErrSubstrings: []string{keyAllowlistHosts, "exa mple.com"},
},
{
name: "allowlist entry not a string",
yaml: signingKeyLine + "allowlist_hosts:\n - 123\n",
wantErrSubstrings: []string{keyAllowlistHosts, "123"},
},
{
name: "allowlist not a list",
yaml: signingKeyLine + "allowlist_hosts:\n key: value\n",
wantErrSubstrings: []string{keyAllowlistHosts},
},
{
name: "signing_key too short",
yaml: "signing_key: short\n",
wantErrSubstrings: []string{keySigningKey},
},
{
name: "signing_key missing",
yaml: "port: 8080\n",
wantErrSubstrings: []string{keySigningKey},
},
{
name: "state_dir explicitly empty",
yaml: signingKeyLine + "state_dir: \"\"\n",
wantErrSubstrings: []string{keyStateDir},
},
{
name: "sentry_dsn not a URL",
yaml: signingKeyLine + "sentry_dsn: \"not a url\"\n",
wantErrSubstrings: []string{keySentryDSN, "not a url"},
},
{
name: "metrics username without password",
yaml: signingKeyLine + "metrics:\n username: bob\n",
wantErrSubstrings: []string{keyMetrics},
},
{
name: "metrics password without username",
yaml: signingKeyLine + "metrics:\n password: hunter2\n",
wantErrSubstrings: []string{keyMetrics},
},
} }
} }
@@ -158,140 +336,84 @@ allowlist_hosts: "s3.sneak.cloud, sneak.berlin"
// value must produce a startup error naming the offending key, never // value must produce a startup error naming the offending key, never
// silently fall back to the default. // silently fall back to the default.
func TestSetButInvalidValueAbortsStartup(t *testing.T) { func TestSetButInvalidValueAbortsStartup(t *testing.T) {
signingKeyLine := "signing_key: " + validTestSigningKey + "\n" t.Parallel()
cases := []struct { runAbortCases(t, append(
name string invalidScalarValueCases(), invalidHostAndCredentialCases()...))
yaml string }
// wantErrSubstrings must all appear in the error message.
wantErrSubstrings []string // explicitNullValueCases are configs where a key is explicitly set to
}{ // null (including the bare "key:" form and the "~" alias); each must
// abort startup naming the key.
func explicitNullValueCases() []abortCase {
return []abortCase{
{ {
name: "port not a number", name: "port explicit null",
yaml: signingKeyLine + "port: banana\n", yaml: signingKeyLine + "port: null\n",
wantErrSubstrings: []string{"port", "banana"}, wantErrSubstrings: []string{keyPort, nullValueText},
}, },
{ {
name: "port zero", name: "port bare key no value",
yaml: signingKeyLine + "port: 0\n", yaml: signingKeyLine + "port:\n",
wantErrSubstrings: []string{"port", "0"}, wantErrSubstrings: []string{keyPort, nullValueText},
}, },
{ {
name: "port above 65535", name: "debug tilde null",
yaml: signingKeyLine + "port: 99999\n", yaml: signingKeyLine + "debug: ~\n",
wantErrSubstrings: []string{"port", "99999"}, wantErrSubstrings: []string{keyDebug, nullValueText},
}, },
{ {
name: "port fractional", name: "maintenance_mode null",
yaml: signingKeyLine + "port: 8080.5\n", yaml: signingKeyLine + "maintenance_mode: null\n",
wantErrSubstrings: []string{"port", "8080.5"}, wantErrSubstrings: []string{keyMaintenanceMode, nullValueText},
}, },
{ {
name: "debug not a bool", name: "allow_http null",
yaml: signingKeyLine + "debug: notabool\n", yaml: signingKeyLine + "allow_http: null\n",
wantErrSubstrings: []string{"debug", "notabool"}, wantErrSubstrings: []string{keyAllowHTTP, nullValueText},
}, },
{ {
name: "maintenance_mode not a bool", name: "state_dir null",
yaml: signingKeyLine + "maintenance_mode: sometimes\n", yaml: signingKeyLine + "state_dir: null\n",
wantErrSubstrings: []string{"maintenance_mode", "sometimes"}, wantErrSubstrings: []string{keyStateDir, nullValueText},
}, },
{ {
name: "allow_http numeric", name: "db_url null",
yaml: signingKeyLine + "allow_http: 2\n", yaml: signingKeyLine + "db_url: null\n",
wantErrSubstrings: []string{"allow_http", "2"}, wantErrSubstrings: []string{keyDBURL, nullValueText},
}, },
{ {
name: "upstream_connections_per_host zero", name: "sentry_dsn null",
yaml: signingKeyLine + "upstream_connections_per_host: 0\n", yaml: signingKeyLine + "sentry_dsn: null\n",
wantErrSubstrings: []string{"upstream_connections_per_host", "0"}, wantErrSubstrings: []string{keySentryDSN, nullValueText},
}, },
{ {
name: "upstream_connections_per_host negative", name: "upstream_connections_per_host null",
yaml: signingKeyLine + "upstream_connections_per_host: -3\n", yaml: signingKeyLine + "upstream_connections_per_host: null\n",
wantErrSubstrings: []string{"upstream_connections_per_host", "-3"}, wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, nullValueText},
}, },
{ {
name: "upstream_connections_per_host not a number", name: "allowlist_hosts null",
yaml: signingKeyLine + "upstream_connections_per_host: many\n", yaml: signingKeyLine + "allowlist_hosts: null\n",
wantErrSubstrings: []string{"upstream_connections_per_host", "many"}, wantErrSubstrings: []string{keyAllowlistHosts, nullValueText},
}, },
{ {
name: "allowlist host with scheme", name: "signing_key null",
yaml: signingKeyLine + "allowlist_hosts:\n - https://example.com\n", yaml: "signing_key: null\n",
wantErrSubstrings: []string{keySigningKey, nullValueText},
},
{
name: "metrics null",
yaml: signingKeyLine + "metrics: null\n",
wantErrSubstrings: []string{keyMetrics, nullValueText},
},
{
name: "metrics subkeys null",
yaml: signingKeyLine + "metrics:\n username: null\n password: null\n",
wantErrSubstrings: []string{ wantErrSubstrings: []string{
"allowlist_hosts", "https://example.com", keyMetricsUsername, keyMetricsPassword, nullValueText,
}, },
}, },
{
name: "allowlist host with path",
yaml: signingKeyLine + "allowlist_hosts:\n - example.com/images\n",
wantErrSubstrings: []string{
"allowlist_hosts", "example.com/images",
},
},
{
name: "allowlist host with whitespace",
yaml: signingKeyLine + "allowlist_hosts:\n - \"exa mple.com\"\n",
wantErrSubstrings: []string{"allowlist_hosts", "exa mple.com"},
},
{
name: "allowlist entry not a string",
yaml: signingKeyLine + "allowlist_hosts:\n - 123\n",
wantErrSubstrings: []string{"allowlist_hosts", "123"},
},
{
name: "allowlist not a list",
yaml: signingKeyLine + "allowlist_hosts:\n key: value\n",
wantErrSubstrings: []string{"allowlist_hosts"},
},
{
name: "signing_key too short",
yaml: "signing_key: short\n",
wantErrSubstrings: []string{"signing_key"},
},
{
name: "signing_key missing",
yaml: "port: 8080\n",
wantErrSubstrings: []string{"signing_key"},
},
{
name: "state_dir explicitly empty",
yaml: signingKeyLine + "state_dir: \"\"\n",
wantErrSubstrings: []string{"state_dir"},
},
{
name: "sentry_dsn not a URL",
yaml: signingKeyLine + "sentry_dsn: \"not a url\"\n",
wantErrSubstrings: []string{"sentry_dsn", "not a url"},
},
{
name: "metrics username without password",
yaml: signingKeyLine + "metrics:\n username: bob\n",
wantErrSubstrings: []string{"metrics"},
},
{
name: "metrics password without username",
yaml: signingKeyLine + "metrics:\n password: hunter2\n",
wantErrSubstrings: []string{"metrics"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, err := configFromYAML(t, tc.yaml)
if err == nil {
t.Fatalf("config with %s must abort startup, got config: %+v", tc.name, c)
}
t.Logf("got expected error: %v", err)
for _, want := range tc.wantErrSubstrings {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not mention %q", err.Error(), want)
}
}
})
} }
} }
@@ -300,99 +422,9 @@ func TestSetButInvalidValueAbortsStartup(t *testing.T) {
// startup naming the key. An explicit null is a SET value: it must // startup naming the key. An explicit null is a SET value: it must
// never silently fall back to the default the way an omitted key does. // never silently fall back to the default the way an omitted key does.
func TestExplicitNullValueAbortsStartup(t *testing.T) { func TestExplicitNullValueAbortsStartup(t *testing.T) {
signingKeyLine := "signing_key: " + validTestSigningKey + "\n" t.Parallel()
cases := []struct { runAbortCases(t, explicitNullValueCases())
name string
yaml string
// wantErrSubstrings must all appear in the error message.
wantErrSubstrings []string
}{
{
name: "port explicit null",
yaml: signingKeyLine + "port: null\n",
wantErrSubstrings: []string{"port", "null"},
},
{
name: "port bare key no value",
yaml: signingKeyLine + "port:\n",
wantErrSubstrings: []string{"port", "null"},
},
{
name: "debug tilde null",
yaml: signingKeyLine + "debug: ~\n",
wantErrSubstrings: []string{"debug", "null"},
},
{
name: "maintenance_mode null",
yaml: signingKeyLine + "maintenance_mode: null\n",
wantErrSubstrings: []string{"maintenance_mode", "null"},
},
{
name: "allow_http null",
yaml: signingKeyLine + "allow_http: null\n",
wantErrSubstrings: []string{"allow_http", "null"},
},
{
name: "state_dir null",
yaml: signingKeyLine + "state_dir: null\n",
wantErrSubstrings: []string{"state_dir", "null"},
},
{
name: "db_url null",
yaml: signingKeyLine + "db_url: null\n",
wantErrSubstrings: []string{"db_url", "null"},
},
{
name: "sentry_dsn null",
yaml: signingKeyLine + "sentry_dsn: null\n",
wantErrSubstrings: []string{"sentry_dsn", "null"},
},
{
name: "upstream_connections_per_host null",
yaml: signingKeyLine + "upstream_connections_per_host: null\n",
wantErrSubstrings: []string{"upstream_connections_per_host", "null"},
},
{
name: "allowlist_hosts null",
yaml: signingKeyLine + "allowlist_hosts: null\n",
wantErrSubstrings: []string{"allowlist_hosts", "null"},
},
{
name: "signing_key null",
yaml: "signing_key: null\n",
wantErrSubstrings: []string{"signing_key", "null"},
},
{
name: "metrics null",
yaml: signingKeyLine + "metrics: null\n",
wantErrSubstrings: []string{"metrics", "null"},
},
{
name: "metrics subkeys null",
yaml: signingKeyLine + "metrics:\n username: null\n password: null\n",
wantErrSubstrings: []string{
"metrics.username", "metrics.password", "null",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, err := configFromYAML(t, tc.yaml)
if err == nil {
t.Fatalf("config with %s must abort startup, got config: %+v", tc.name, c)
}
t.Logf("got expected error: %v", err)
for _, want := range tc.wantErrSubstrings {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not mention %q", err.Error(), want)
}
}
})
}
} }
// TestExplicitlyEmptyDBURLAbortsStartup verifies that db_url set to an // TestExplicitlyEmptyDBURLAbortsStartup verifies that db_url set to an
@@ -400,7 +432,9 @@ func TestExplicitNullValueAbortsStartup(t *testing.T) {
// a default, and defaults apply only to omitted keys. This matches // a default, and defaults apply only to omitted keys. This matches
// state_dir, where an explicitly empty value already aborts. // state_dir, where an explicitly empty value already aborts.
func TestExplicitlyEmptyDBURLAbortsStartup(t *testing.T) { func TestExplicitlyEmptyDBURLAbortsStartup(t *testing.T) {
yamlContent := "signing_key: " + validTestSigningKey + "\ndb_url: \"\"\n" t.Parallel()
yamlContent := signingKeyLine + "db_url: \"\"\n"
c, err := configFromYAML(t, yamlContent) c, err := configFromYAML(t, yamlContent)
if err == nil { if err == nil {
@@ -409,7 +443,7 @@ func TestExplicitlyEmptyDBURLAbortsStartup(t *testing.T) {
t.Logf("got expected error: %v", err) t.Logf("got expected error: %v", err)
if !strings.Contains(err.Error(), "db_url") { if !strings.Contains(err.Error(), keyDBURL) {
t.Errorf("error %q does not name the offending key db_url", err.Error()) t.Errorf("error %q does not name the offending key db_url", err.Error())
} }
} }
@@ -420,10 +454,12 @@ func TestExplicitlyEmptyDBURLAbortsStartup(t *testing.T) {
// host written in FQDN trailing-dot form (e.g. evil.com.) and // host written in FQDN trailing-dot form (e.g. evil.com.) and
// effectively disable URL signing with a single character. // effectively disable URL signing with a single character.
func TestAllowlistHostsRejectsDotOnlyEntries(t *testing.T) { func TestAllowlistHostsRejectsDotOnlyEntries(t *testing.T) {
signingKeyLine := "signing_key: " + validTestSigningKey + "\n" t.Parallel()
for _, entry := range []string{".", ".."} { for _, entry := range []string{".", ".."} {
t.Run(entry, func(t *testing.T) { t.Run(entry, func(t *testing.T) {
t.Parallel()
yamlContent := signingKeyLine + yamlContent := signingKeyLine +
"allowlist_hosts:\n - \"" + entry + "\"\n" "allowlist_hosts:\n - \"" + entry + "\"\n"
@@ -435,7 +471,7 @@ func TestAllowlistHostsRejectsDotOnlyEntries(t *testing.T) {
t.Logf("got expected error: %v", err) t.Logf("got expected error: %v", err)
if !strings.Contains(err.Error(), "allowlist_hosts") { if !strings.Contains(err.Error(), keyAllowlistHosts) {
t.Errorf("error %q does not name the offending key allowlist_hosts", t.Errorf("error %q does not name the offending key allowlist_hosts",
err.Error()) err.Error())
} }
@@ -444,8 +480,9 @@ func TestAllowlistHostsRejectsDotOnlyEntries(t *testing.T) {
} }
func TestUnknownTopLevelKeyAbortsStartup(t *testing.T) { func TestUnknownTopLevelKeyAbortsStartup(t *testing.T) {
yamlContent := `signing_key: ` + validTestSigningKey + ` t.Parallel()
whitelist_hosts:
yamlContent := signingKeyLine + `whitelist_hosts:
- example.com - example.com
` `
@@ -462,8 +499,9 @@ whitelist_hosts:
} }
func TestUnknownMetricsSubkeyAbortsStartup(t *testing.T) { func TestUnknownMetricsSubkeyAbortsStartup(t *testing.T) {
yamlContent := `signing_key: ` + validTestSigningKey + ` t.Parallel()
metrics:
yamlContent := signingKeyLine + `metrics:
username: bob username: bob
password: hunter2 password: hunter2
port: 9100 port: 9100
@@ -482,13 +520,17 @@ metrics:
} }
func TestEnvSectionIsPermitted(t *testing.T) { func TestEnvSectionIsPermitted(t *testing.T) {
yamlContent := `signing_key: ` + validTestSigningKey + ` t.Parallel()
env:
yamlContent := signingKeyLine + `env:
PIXA_TEST_ENV_INJECTION: injected PIXA_TEST_ENV_INJECTION: injected
` `
if _, err := configFromYAML(t, yamlContent); err != nil { _, err := configFromYAML(t, yamlContent)
t.Fatalf("env section must be permitted (smartconfig consumes it), got error: %v", err) if err != nil {
t.Fatalf(
"env section must be permitted (smartconfig consumes it), got error: %v",
err)
} }
} }
@@ -496,7 +538,8 @@ func TestMalformedConfigFileAbortsStartup(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yml") configPath := filepath.Join(tmpDir, "config.yml")
if err := os.WriteFile(configPath, []byte("port: [unclosed\n"), 0o600); err != nil { err := os.WriteFile(configPath, []byte("port: [unclosed\n"), 0o600)
if err != nil {
t.Fatalf("failed to write malformed config: %v", err) t.Fatalf("failed to write malformed config: %v", err)
} }
@@ -505,7 +548,7 @@ func TestMalformedConfigFileAbortsStartup(t *testing.T) {
t.Setenv("PIXA_CONFIG_PATH", "") t.Setenv("PIXA_CONFIG_PATH", "")
t.Chdir(tmpDir) t.Chdir(tmpDir)
log := slog.New(slog.NewTextHandler(io.Discard, nil)) log := slog.New(slog.DiscardHandler)
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app") sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
if err == nil { if err == nil {
@@ -516,10 +559,14 @@ func TestMalformedConfigFileAbortsStartup(t *testing.T) {
} }
func TestEnsureStateDirCreatesDirectory(t *testing.T) { func TestEnsureStateDirCreatesDirectory(t *testing.T) {
t.Parallel()
stateDir := filepath.Join(t.TempDir(), "nested", "state") stateDir := filepath.Join(t.TempDir(), "nested", "state")
c := &Config{StateDir: stateDir} c := &Config{StateDir: stateDir}
if err := c.ensureStateDirWritable(); err != nil {
err := c.ensureStateDirWritable()
if err != nil {
t.Fatalf("creatable state_dir must validate, got error: %v", err) t.Fatalf("creatable state_dir must validate, got error: %v", err)
} }
@@ -530,6 +577,8 @@ func TestEnsureStateDirCreatesDirectory(t *testing.T) {
} }
func TestEnsureStateDirFailsOnUncreatablePath(t *testing.T) { func TestEnsureStateDirFailsOnUncreatablePath(t *testing.T) {
t.Parallel()
// A path below /dev/null can never be created, even when running // A path below /dev/null can never be created, even when running
// as root (as in the Docker build). // as root (as in the Docker build).
c := &Config{StateDir: "/dev/null/pixa-state"} c := &Config{StateDir: "/dev/null/pixa-state"}
@@ -541,7 +590,7 @@ func TestEnsureStateDirFailsOnUncreatablePath(t *testing.T) {
t.Logf("got expected error: %v", err) t.Logf("got expected error: %v", err)
if !strings.Contains(err.Error(), "state_dir") { if !strings.Contains(err.Error(), keyStateDir) {
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())
} }
} }

View File

@@ -5,6 +5,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"embed" "embed"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"path/filepath" "path/filepath"
@@ -29,10 +30,15 @@ const bootstrapVersion = 0
// Params defines dependencies for Database. // Params defines dependencies for Database.
type Params struct { type Params struct {
fx.In fx.In
Logger *logger.Logger Logger *logger.Logger
Config *config.Config Config *config.Config
} }
// errInvalidMigrationFilename is returned when a migration filename does
// not match the "<version>[_<description>].sql" pattern.
var errInvalidMigrationFilename = errors.New("invalid migration filename")
// Database wraps the SQL database connection. // Database wraps the SQL database connection.
type Database struct { type Database struct {
db *sql.DB db *sql.DB
@@ -48,33 +54,31 @@ type Database struct {
func ParseMigrationVersion(filename string) (int, error) { func ParseMigrationVersion(filename string) (int, error) {
name := strings.TrimSuffix(filename, filepath.Ext(filename)) name := strings.TrimSuffix(filename, filepath.Ext(filename))
if name == "" { if name == "" {
return 0, fmt.Errorf("invalid migration filename %q: empty name", filename) return 0, fmt.Errorf("%w %q: empty name", errInvalidMigrationFilename, filename)
} }
// Split on underscore to separate version from description. // Split on underscore to separate version from description.
// If there's no underscore, the entire stem is the version. // If there's no underscore, the entire stem is the version.
versionStr := name versionStr, _, _ := strings.Cut(name, "_")
if idx := strings.IndexByte(name, '_'); idx >= 0 {
versionStr = name[:idx]
}
if versionStr == "" { if versionStr == "" {
return 0, fmt.Errorf("invalid migration filename %q: empty version prefix", filename) return 0, fmt.Errorf(
"%w %q: empty version prefix", errInvalidMigrationFilename, filename,
)
} }
// Validate the version is purely numeric. // Validate the version is purely numeric.
for _, ch := range versionStr { for _, ch := range versionStr {
if ch < '0' || ch > '9' { if ch < '0' || ch > '9' {
return 0, fmt.Errorf( return 0, fmt.Errorf(
"invalid migration filename %q: version %q contains non-numeric character %q", "%w %q: version %q contains non-numeric character %q",
filename, versionStr, string(ch), errInvalidMigrationFilename, filename, versionStr, string(ch),
) )
} }
} }
version, err := strconv.Atoi(versionStr) version, err := strconv.Atoi(versionStr)
if err != nil { if err != nil {
return 0, fmt.Errorf("invalid migration filename %q: %w", filename, err) return 0, fmt.Errorf("%w %q: %w", errInvalidMigrationFilename, filename, err)
} }
return version, nil return version, nil
@@ -97,6 +101,7 @@ func New(lc fx.Lifecycle, params Params) (*Database, error) {
}, },
OnStop: func(_ context.Context) error { OnStop: func(_ context.Context) error {
s.log.Info("Database OnStop Hook") s.log.Info("Database OnStop Hook")
if s.db != nil { if s.db != nil {
return s.db.Close() return s.db.Close()
} }
@@ -108,30 +113,6 @@ func New(lc fx.Lifecycle, params Params) (*Database, error) {
return s, nil return s, nil
} }
func (s *Database) connect(ctx context.Context) error {
dbURL := s.config.DBURL
s.log.Info("connecting to database", "url", dbURL)
db, err := sql.Open("sqlite", dbURL)
if err != nil {
s.log.Error("failed to open database", "error", err)
return err
}
if err := db.PingContext(ctx); err != nil {
s.log.Error("failed to ping database", "error", err)
return err
}
s.db = db
s.log.Info("database connected")
return ApplyMigrations(ctx, s.db, s.log)
}
// collectMigrations reads the embedded schema directory and returns // collectMigrations reads the embedded schema directory and returns
// migration filenames sorted lexicographically. // migration filenames sorted lexicographically.
func collectMigrations() ([]string, error) { func collectMigrations() ([]string, error) {
@@ -191,7 +172,8 @@ func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger)
// This is exported so tests can apply the real schema without the full fx // This is exported so tests can apply the real schema without the full fx
// lifecycle. // lifecycle.
func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error { func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
if err := bootstrapMigrationsTable(ctx, db, log); err != nil { err := bootstrapMigrationsTable(ctx, db, log)
if err != nil {
return err return err
} }
@@ -261,3 +243,28 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
func (s *Database) DB() *sql.DB { func (s *Database) DB() *sql.DB {
return s.db return s.db
} }
func (s *Database) connect(ctx context.Context) error {
dbURL := s.config.DBURL
s.log.Info("connecting to database", "url", dbURL)
db, err := sql.Open("sqlite", dbURL)
if err != nil {
s.log.Error("failed to open database", "error", err)
return err
}
err = db.PingContext(ctx)
if err != nil {
s.log.Error("failed to ping database", "error", err)
return err
}
s.db = db
s.log.Info("database connected")
return ApplyMigrations(ctx, s.db, s.log)
}

View File

@@ -1,7 +1,6 @@
package database package database
import ( import (
"context"
"database/sql" "database/sql"
"testing" "testing"
@@ -17,12 +16,14 @@ func openTestDB(t *testing.T) *sql.DB {
t.Fatalf("failed to open test db: %v", err) t.Fatalf("failed to open test db: %v", err)
} }
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { _ = db.Close() })
return db return db
} }
func TestParseMigrationVersion(t *testing.T) { func TestParseMigrationVersion(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
name string name string
filename string filename string
@@ -78,6 +79,8 @@ func TestParseMigrationVersion(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := ParseMigrationVersion(tt.filename) got, err := ParseMigrationVersion(tt.filename)
if tt.wantErr { if tt.wantErr {
if err == nil { if err == nil {
@@ -101,37 +104,50 @@ func TestParseMigrationVersion(t *testing.T) {
} }
func TestApplyMigrations_CreatesSchemaAndTables(t *testing.T) { func TestApplyMigrations_CreatesSchemaAndTables(t *testing.T) {
db := openTestDB(t) t.Parallel()
ctx := context.Background()
if err := ApplyMigrations(ctx, db, nil); err != nil { db := openTestDB(t)
ctx := t.Context()
err := ApplyMigrations(ctx, db, nil)
if err != nil {
t.Fatalf("ApplyMigrations failed: %v", err) t.Fatalf("ApplyMigrations failed: %v", err)
} }
// The schema_migrations table must exist and contain at least // The schema_migrations table must exist and contain at least
// version 0 (the bootstrap) and 1 (the initial schema). // version 0 (the bootstrap) and 1 (the initial schema).
rows, err := db.Query("SELECT version FROM schema_migrations ORDER BY version") rows, err := db.QueryContext(
ctx, "SELECT version FROM schema_migrations ORDER BY version",
)
if err != nil { if err != nil {
t.Fatalf("failed to query schema_migrations: %v", err) t.Fatalf("failed to query schema_migrations: %v", err)
} }
defer rows.Close()
defer func() { _ = rows.Close() }()
var versions []int var versions []int
for rows.Next() { for rows.Next() {
var v int var v int
if err := rows.Scan(&v); err != nil {
t.Fatalf("failed to scan version: %v", err) scanErr := rows.Scan(&v)
if scanErr != nil {
t.Fatalf("failed to scan version: %v", scanErr)
} }
versions = append(versions, v) versions = append(versions, v)
} }
if err := rows.Err(); err != nil { err = rows.Err()
if err != nil {
t.Fatalf("row iteration error: %v", err) t.Fatalf("row iteration error: %v", err)
} }
if len(versions) < 2 { if len(versions) < 2 {
t.Fatalf("expected at least 2 migrations recorded, got %d: %v", len(versions), versions) t.Fatalf(
"expected at least 2 migrations recorded, got %d: %v",
len(versions), versions,
)
} }
if versions[0] != 0 { if versions[0] != 0 {
@@ -143,10 +159,15 @@ func TestApplyMigrations_CreatesSchemaAndTables(t *testing.T) {
} }
// Verify that the application tables created by 001.sql exist. // Verify that the application tables created by 001.sql exist.
for _, table := range []string{"source_content", "source_metadata", "output_content", "request_cache", "negative_cache", "cache_stats"} { tables := []string{
"source_content", "source_metadata", "output_content",
"request_cache", "negative_cache", "cache_stats",
}
for _, table := range tables {
var count int var count int
err := db.QueryRow( err := db.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?",
table, table,
).Scan(&count) ).Scan(&count)
@@ -161,22 +182,28 @@ func TestApplyMigrations_CreatesSchemaAndTables(t *testing.T) {
} }
func TestApplyMigrations_Idempotent(t *testing.T) { func TestApplyMigrations_Idempotent(t *testing.T) {
db := openTestDB(t) t.Parallel()
ctx := context.Background()
if err := ApplyMigrations(ctx, db, nil); err != nil { db := openTestDB(t)
ctx := t.Context()
err := ApplyMigrations(ctx, db, nil)
if err != nil {
t.Fatalf("first ApplyMigrations failed: %v", err) t.Fatalf("first ApplyMigrations failed: %v", err)
} }
// Running a second time must succeed without errors. // Running a second time must succeed without errors.
if err := ApplyMigrations(ctx, db, nil); err != nil { err = ApplyMigrations(ctx, db, nil)
if err != nil {
t.Fatalf("second ApplyMigrations failed: %v", err) t.Fatalf("second ApplyMigrations failed: %v", err)
} }
// Verify no duplicate rows in schema_migrations. // Verify no duplicate rows in schema_migrations.
var count int var count int
err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = 0").Scan(&count) err = db.QueryRowContext(
ctx, "SELECT COUNT(*) FROM schema_migrations WHERE version = 0",
).Scan(&count)
if err != nil { if err != nil {
t.Fatalf("failed to count version 0 rows: %v", err) t.Fatalf("failed to count version 0 rows: %v", err)
} }
@@ -187,17 +214,21 @@ func TestApplyMigrations_Idempotent(t *testing.T) {
} }
func TestBootstrapMigrationsTable_FreshDatabase(t *testing.T) { func TestBootstrapMigrationsTable_FreshDatabase(t *testing.T) {
db := openTestDB(t) t.Parallel()
ctx := context.Background()
if err := bootstrapMigrationsTable(ctx, db, nil); err != nil { db := openTestDB(t)
ctx := t.Context()
err := bootstrapMigrationsTable(ctx, db, nil)
if err != nil {
t.Fatalf("bootstrapMigrationsTable failed: %v", err) t.Fatalf("bootstrapMigrationsTable failed: %v", err)
} }
// schema_migrations table must exist. // schema_migrations table must exist.
var tableCount int var tableCount int
err := db.QueryRow( err = db.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'", "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
).Scan(&tableCount) ).Scan(&tableCount)
if err != nil { if err != nil {
@@ -211,8 +242,8 @@ func TestBootstrapMigrationsTable_FreshDatabase(t *testing.T) {
// Version 0 must be recorded. // Version 0 must be recorded.
var recorded int var recorded int
err = db.QueryRow( err = db.QueryRowContext(
"SELECT COUNT(*) FROM schema_migrations WHERE version = 0", ctx, "SELECT COUNT(*) FROM schema_migrations WHERE version = 0",
).Scan(&recorded) ).Scan(&recorded)
if err != nil { if err != nil {
t.Fatalf("failed to check version: %v", err) t.Fatalf("failed to check version: %v", err)

View File

@@ -48,7 +48,8 @@ type Generator struct {
key [seal.KeySize]byte key [seal.KeySize]byte
} }
// NewGenerator creates an encrypted URL generator with a key derived from the signing key. // NewGenerator creates an encrypted URL generator with a key derived
// from the signing key.
func NewGenerator(signingKey string) (*Generator, error) { func NewGenerator(signingKey string) (*Generator, error) {
key, err := seal.DeriveKey([]byte(signingKey), urlKeySalt) key, err := seal.DeriveKey([]byte(signingKey), urlKeySalt)
if err != nil { if err != nil {
@@ -77,7 +78,8 @@ func (g *Generator) Parse(token string) (*Payload, error) {
// Decrypt // Decrypt
data, err := seal.Decrypt(g.key, token) data, err := seal.Decrypt(g.key, token)
if err != nil { if err != nil {
if errors.Is(err, seal.ErrDecryptionFailed) || errors.Is(err, seal.ErrInvalidPayload) { if errors.Is(err, seal.ErrDecryptionFailed) ||
errors.Is(err, seal.ErrInvalidPayload) {
return nil, ErrDecryptFailed return nil, ErrDecryptFailed
} }
@@ -86,7 +88,9 @@ func (g *Generator) Parse(token string) (*Payload, error) {
// CBOR decode // CBOR decode
var p Payload var p Payload
if err := cbor.Unmarshal(data, &p); err != nil {
err = cbor.Unmarshal(data, &p)
if err != nil {
return nil, ErrInvalidFormat return nil, ErrInvalidFormat
} }

View File

@@ -1,22 +1,33 @@
package encurl package encurl_test
import ( import (
"errors"
"testing" "testing"
"time" "time"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imgcache"
) )
// Shared test fixture strings.
const (
testSourceHost = "cdn.example.com"
testSourcePath = "/images/photo.jpg"
testSourceQuery = "v=2"
)
func TestGenerator_GenerateAndParse(t *testing.T) { func TestGenerator_GenerateAndParse(t *testing.T) {
gen, err := NewGenerator("test-signing-key-12345") t.Parallel()
gen, err := encurl.NewGenerator("test-signing-key-12345")
if err != nil { if err != nil {
t.Fatalf("NewGenerator() error = %v", err) t.Fatalf("NewGenerator() error = %v", err)
} }
payload := &Payload{ payload := &encurl.Payload{
SourceHost: "cdn.example.com", SourceHost: testSourceHost,
SourcePath: "/images/photo.jpg", SourcePath: testSourcePath,
SourceQuery: "v=2", SourceQuery: testSourceQuery,
Width: 800, Width: 800,
Height: 600, Height: 600,
Format: imgcache.FormatWebP, Format: imgcache.FormatWebP,
@@ -43,38 +54,48 @@ func TestGenerator_GenerateAndParse(t *testing.T) {
if parsed.SourceHost != payload.SourceHost { if parsed.SourceHost != payload.SourceHost {
t.Errorf("SourceHost = %q, want %q", parsed.SourceHost, payload.SourceHost) t.Errorf("SourceHost = %q, want %q", parsed.SourceHost, payload.SourceHost)
} }
if parsed.SourcePath != payload.SourcePath { if parsed.SourcePath != payload.SourcePath {
t.Errorf("SourcePath = %q, want %q", parsed.SourcePath, payload.SourcePath) t.Errorf("SourcePath = %q, want %q", parsed.SourcePath, payload.SourcePath)
} }
if parsed.SourceQuery != payload.SourceQuery { if parsed.SourceQuery != payload.SourceQuery {
t.Errorf("SourceQuery = %q, want %q", parsed.SourceQuery, payload.SourceQuery) t.Errorf("SourceQuery = %q, want %q", parsed.SourceQuery, payload.SourceQuery)
} }
if parsed.Width != payload.Width { if parsed.Width != payload.Width {
t.Errorf("Width = %d, want %d", parsed.Width, payload.Width) t.Errorf("Width = %d, want %d", parsed.Width, payload.Width)
} }
if parsed.Height != payload.Height { if parsed.Height != payload.Height {
t.Errorf("Height = %d, want %d", parsed.Height, payload.Height) t.Errorf("Height = %d, want %d", parsed.Height, payload.Height)
} }
if parsed.Format != payload.Format { if parsed.Format != payload.Format {
t.Errorf("Format = %q, want %q", parsed.Format, payload.Format) t.Errorf("Format = %q, want %q", parsed.Format, payload.Format)
} }
if parsed.Quality != payload.Quality { if parsed.Quality != payload.Quality {
t.Errorf("Quality = %d, want %d", parsed.Quality, payload.Quality) t.Errorf("Quality = %d, want %d", parsed.Quality, payload.Quality)
} }
if parsed.FitMode != payload.FitMode { if parsed.FitMode != payload.FitMode {
t.Errorf("FitMode = %q, want %q", parsed.FitMode, payload.FitMode) t.Errorf("FitMode = %q, want %q", parsed.FitMode, payload.FitMode)
} }
if parsed.ExpiresAt != payload.ExpiresAt { if parsed.ExpiresAt != payload.ExpiresAt {
t.Errorf("ExpiresAt = %d, want %d", parsed.ExpiresAt, payload.ExpiresAt) t.Errorf("ExpiresAt = %d, want %d", parsed.ExpiresAt, payload.ExpiresAt)
} }
} }
func TestGenerator_Parse_Expired(t *testing.T) { func TestGenerator_Parse_Expired(t *testing.T) {
gen, _ := NewGenerator("test-signing-key-12345") t.Parallel()
payload := &Payload{ gen, _ := encurl.NewGenerator("test-signing-key-12345")
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg", payload := &encurl.Payload{
SourceHost: testSourceHost,
SourcePath: testSourcePath,
ExpiresAt: time.Now().Add(-time.Hour).Unix(), // Already expired ExpiresAt: time.Now().Add(-time.Hour).Unix(), // Already expired
} }
@@ -88,13 +109,15 @@ func TestGenerator_Parse_Expired(t *testing.T) {
t.Error("Parse() should fail for expired token") t.Error("Parse() should fail for expired token")
} }
if err != ErrExpired { if !errors.Is(err, encurl.ErrExpired) {
t.Errorf("Parse() error = %v, want %v", err, ErrExpired) t.Errorf("Parse() error = %v, want %v", err, encurl.ErrExpired)
} }
} }
func TestGenerator_Parse_InvalidToken(t *testing.T) { func TestGenerator_Parse_InvalidToken(t *testing.T) {
gen, _ := NewGenerator("test-signing-key-12345") t.Parallel()
gen, _ := encurl.NewGenerator("test-signing-key-12345")
_, err := gen.Parse("not-a-valid-token") _, err := gen.Parse("not-a-valid-token")
if err == nil { if err == nil {
@@ -103,11 +126,13 @@ func TestGenerator_Parse_InvalidToken(t *testing.T) {
} }
func TestGenerator_Parse_TamperedToken(t *testing.T) { func TestGenerator_Parse_TamperedToken(t *testing.T) {
gen, _ := NewGenerator("test-signing-key-12345") t.Parallel()
payload := &Payload{ gen, _ := encurl.NewGenerator("test-signing-key-12345")
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg", payload := &encurl.Payload{
SourceHost: testSourceHost,
SourcePath: testSourcePath,
ExpiresAt: time.Now().Add(time.Hour).Unix(), ExpiresAt: time.Now().Add(time.Hour).Unix(),
} }
@@ -126,12 +151,14 @@ func TestGenerator_Parse_TamperedToken(t *testing.T) {
} }
func TestGenerator_Parse_WrongKey(t *testing.T) { func TestGenerator_Parse_WrongKey(t *testing.T) {
gen1, _ := NewGenerator("signing-key-1") t.Parallel()
gen2, _ := NewGenerator("signing-key-2")
payload := &Payload{ gen1, _ := encurl.NewGenerator("signing-key-1")
SourceHost: "cdn.example.com", gen2, _ := encurl.NewGenerator("signing-key-2")
SourcePath: "/images/photo.jpg",
payload := &encurl.Payload{
SourceHost: testSourceHost,
SourcePath: testSourcePath,
ExpiresAt: time.Now().Add(time.Hour).Unix(), ExpiresAt: time.Now().Add(time.Hour).Unix(),
} }
@@ -144,10 +171,12 @@ func TestGenerator_Parse_WrongKey(t *testing.T) {
} }
func TestPayload_ToImageRequest(t *testing.T) { func TestPayload_ToImageRequest(t *testing.T) {
payload := &Payload{ t.Parallel()
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg", payload := &encurl.Payload{
SourceQuery: "v=2", SourceHost: testSourceHost,
SourcePath: testSourcePath,
SourceQuery: testSourceQuery,
Width: 800, Width: 800,
Height: 600, Height: 600,
Format: imgcache.FormatWebP, Format: imgcache.FormatWebP,
@@ -161,55 +190,68 @@ func TestPayload_ToImageRequest(t *testing.T) {
if req.SourceHost != payload.SourceHost { if req.SourceHost != payload.SourceHost {
t.Errorf("SourceHost = %q, want %q", req.SourceHost, payload.SourceHost) t.Errorf("SourceHost = %q, want %q", req.SourceHost, payload.SourceHost)
} }
if req.SourcePath != payload.SourcePath { if req.SourcePath != payload.SourcePath {
t.Errorf("SourcePath = %q, want %q", req.SourcePath, payload.SourcePath) t.Errorf("SourcePath = %q, want %q", req.SourcePath, payload.SourcePath)
} }
if req.SourceQuery != payload.SourceQuery { if req.SourceQuery != payload.SourceQuery {
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, payload.SourceQuery) t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, payload.SourceQuery)
} }
if req.Size.Width != payload.Width { if req.Size.Width != payload.Width {
t.Errorf("Width = %d, want %d", req.Size.Width, payload.Width) t.Errorf("Width = %d, want %d", req.Size.Width, payload.Width)
} }
if req.Size.Height != payload.Height { if req.Size.Height != payload.Height {
t.Errorf("Height = %d, want %d", req.Size.Height, payload.Height) t.Errorf("Height = %d, want %d", req.Size.Height, payload.Height)
} }
if req.Format != payload.Format { if req.Format != payload.Format {
t.Errorf("Format = %q, want %q", req.Format, payload.Format) t.Errorf("Format = %q, want %q", req.Format, payload.Format)
} }
if req.Quality != payload.Quality { if req.Quality != payload.Quality {
t.Errorf("Quality = %d, want %d", req.Quality, payload.Quality) t.Errorf("Quality = %d, want %d", req.Quality, payload.Quality)
} }
if req.FitMode != payload.FitMode { if req.FitMode != payload.FitMode {
t.Errorf("FitMode = %q, want %q", req.FitMode, payload.FitMode) t.Errorf("FitMode = %q, want %q", req.FitMode, payload.FitMode)
} }
} }
func TestPayload_ToImageRequest_Defaults(t *testing.T) { func TestPayload_ToImageRequest_Defaults(t *testing.T) {
t.Parallel()
// Payload with only required fields - should get defaults // Payload with only required fields - should get defaults
payload := &Payload{ payload := &encurl.Payload{
SourceHost: "cdn.example.com", SourceHost: testSourceHost,
SourcePath: "/images/photo.jpg", SourcePath: testSourcePath,
ExpiresAt: time.Now().Add(time.Hour).Unix(), ExpiresAt: time.Now().Add(time.Hour).Unix(),
} }
req := payload.ToImageRequest() req := payload.ToImageRequest()
if req.Format != DefaultFormat { if req.Format != encurl.DefaultFormat {
t.Errorf("Format = %q, want default %q", req.Format, DefaultFormat) t.Errorf("Format = %q, want default %q", req.Format, encurl.DefaultFormat)
} }
if req.Quality != DefaultQuality {
t.Errorf("Quality = %d, want default %d", req.Quality, DefaultQuality) if req.Quality != encurl.DefaultQuality {
t.Errorf("Quality = %d, want default %d", req.Quality, encurl.DefaultQuality)
} }
if req.FitMode != DefaultFitMode {
t.Errorf("FitMode = %q, want default %q", req.FitMode, DefaultFitMode) if req.FitMode != encurl.DefaultFitMode {
t.Errorf("FitMode = %q, want default %q", req.FitMode, encurl.DefaultFitMode)
} }
} }
func TestFromImageRequest(t *testing.T) { func TestFromImageRequest(t *testing.T) {
t.Parallel()
req := &imgcache.ImageRequest{ req := &imgcache.ImageRequest{
SourceHost: "cdn.example.com", SourceHost: testSourceHost,
SourcePath: "/images/photo.jpg", SourcePath: testSourcePath,
SourceQuery: "v=2", SourceQuery: testSourceQuery,
Size: imgcache.Size{Width: 800, Height: 600}, Size: imgcache.Size{Width: 800, Height: 600},
Format: imgcache.FormatWebP, Format: imgcache.FormatWebP,
Quality: 90, Quality: 90,
@@ -217,52 +259,62 @@ func TestFromImageRequest(t *testing.T) {
} }
expiresAt := time.Now().Add(time.Hour) expiresAt := time.Now().Add(time.Hour)
payload := FromImageRequest(req, expiresAt) payload := encurl.FromImageRequest(req, expiresAt)
if payload.SourceHost != req.SourceHost { if payload.SourceHost != req.SourceHost {
t.Errorf("SourceHost = %q, want %q", payload.SourceHost, req.SourceHost) t.Errorf("SourceHost = %q, want %q", payload.SourceHost, req.SourceHost)
} }
if payload.SourcePath != req.SourcePath { if payload.SourcePath != req.SourcePath {
t.Errorf("SourcePath = %q, want %q", payload.SourcePath, req.SourcePath) t.Errorf("SourcePath = %q, want %q", payload.SourcePath, req.SourcePath)
} }
if payload.Width != req.Size.Width { if payload.Width != req.Size.Width {
t.Errorf("Width = %d, want %d", payload.Width, req.Size.Width) t.Errorf("Width = %d, want %d", payload.Width, req.Size.Width)
} }
if payload.ExpiresAt != expiresAt.Unix() { if payload.ExpiresAt != expiresAt.Unix() {
t.Errorf("ExpiresAt = %d, want %d", payload.ExpiresAt, expiresAt.Unix()) t.Errorf("ExpiresAt = %d, want %d", payload.ExpiresAt, expiresAt.Unix())
} }
} }
func TestFromImageRequest_OmitsDefaults(t *testing.T) { func TestFromImageRequest_OmitsDefaults(t *testing.T) {
// Request with default values - payload should omit them for smaller encoding t.Parallel()
// Request with default values - payload should omit them for
// smaller encoding
req := &imgcache.ImageRequest{ req := &imgcache.ImageRequest{
SourceHost: "cdn.example.com", SourceHost: testSourceHost,
SourcePath: "/images/photo.jpg", SourcePath: testSourcePath,
Format: DefaultFormat, Format: encurl.DefaultFormat,
Quality: DefaultQuality, Quality: encurl.DefaultQuality,
FitMode: DefaultFitMode, FitMode: encurl.DefaultFitMode,
} }
payload := FromImageRequest(req, time.Now().Add(time.Hour)) payload := encurl.FromImageRequest(req, time.Now().Add(time.Hour))
// These should be zero/empty because they match defaults // These should be zero/empty because they match defaults
if payload.Format != "" { if payload.Format != "" {
t.Errorf("Format should be empty for default, got %q", payload.Format) t.Errorf("Format should be empty for default, got %q", payload.Format)
} }
if payload.Quality != 0 { if payload.Quality != 0 {
t.Errorf("Quality should be 0 for default, got %d", payload.Quality) t.Errorf("Quality should be 0 for default, got %d", payload.Quality)
} }
if payload.FitMode != "" { if payload.FitMode != "" {
t.Errorf("FitMode should be empty for default, got %q", payload.FitMode) t.Errorf("FitMode should be empty for default, got %q", payload.FitMode)
} }
} }
func TestGenerator_TokenIsURLSafe(t *testing.T) { func TestGenerator_TokenIsURLSafe(t *testing.T) {
gen, _ := NewGenerator("test-signing-key-12345") t.Parallel()
payload := &Payload{ gen, _ := encurl.NewGenerator("test-signing-key-12345")
SourceHost: "cdn.example.com",
SourcePath: "/images/photo.jpg", payload := &encurl.Payload{
SourceHost: testSourceHost,
SourcePath: testSourcePath,
ExpiresAt: time.Now().Add(time.Hour).Unix(), ExpiresAt: time.Now().Add(time.Hour).Unix(),
} }

View File

@@ -35,7 +35,8 @@ func (s *Handlers) HandleRoot() http.HandlerFunc {
// handleLoginPost handles login form submission. // handleLoginPost handles login form submission.
func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) { func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { err := r.ParseForm()
if err != nil {
s.renderLogin(w, "Invalid form data") s.renderLogin(w, "Invalid form data")
return return
@@ -52,7 +53,8 @@ func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
} }
// Create session // Create session
if err := s.sessMgr.CreateSession(w); err != nil { err = s.sessMgr.CreateSession(w)
if err != nil {
s.log.Error("failed to create session", "error", err) s.log.Error("failed to create session", "error", err)
s.renderLogin(w, "Failed to create session") s.renderLogin(w, "Failed to create session")
@@ -83,20 +85,14 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
return return
} }
if err := r.ParseForm(); err != nil { err := r.ParseForm()
if err != nil {
s.renderGenerator(w, &generatorData{Error: "Invalid form data"}) s.renderGenerator(w, &generatorData{Error: "Invalid form data"})
return return
} }
// Parse form values
sourceURL := r.FormValue("url") sourceURL := r.FormValue("url")
widthStr := r.FormValue("width")
heightStr := r.FormValue("height")
format := r.FormValue("format")
qualityStr := r.FormValue("quality")
fit := r.FormValue("fit")
ttlStr := r.FormValue("ttl")
// Validate source URL // Validate source URL
parsed, err := url.Parse(sourceURL) parsed, err := url.Parse(sourceURL)
@@ -106,38 +102,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
return return
} }
// Parse dimensions payload, expiresAt, ttl := buildGeneratePayload(parsed, r.Form)
width, _ := strconv.Atoi(widthStr)
height, _ := strconv.Atoi(heightStr)
quality, _ := strconv.Atoi(qualityStr)
ttl, _ := strconv.Atoi(ttlStr)
if quality <= 0 {
quality = 85
}
// Create payload
// ttl=0 means never expires
var expiresAt time.Time
var expiresAtUnix int64
if ttl > 0 {
expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
expiresAtUnix = expiresAt.Unix()
}
// else expiresAtUnix stays 0 (never expires)
payload := &encurl.Payload{
SourceHost: parsed.Host,
SourcePath: parsed.Path,
SourceQuery: parsed.RawQuery,
Width: width,
Height: height,
Format: imgcache.ImageFormat(format),
Quality: quality,
FitMode: imgcache.FitMode(fit),
ExpiresAt: expiresAtUnix,
}
// Generate encrypted token // Generate encrypted token
token, err := s.encGen.Generate(payload) token, err := s.encGen.Generate(payload)
@@ -148,20 +113,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
return return
} }
// Build full URL (URL-encode the token for safety) generatedURL := s.buildGeneratedURL(r, token, r.FormValue("format"))
scheme := "https"
if s.config.Debug {
scheme = "http"
}
// Determine file extension for the trailing filename
ext := format
if ext == "" || ext == "orig" {
ext = "jpg" // Default extension
}
host := r.Host
generatedURL := scheme + "://" + host + "/v1/e/" + url.PathEscape(token) + "/img." + ext
// Format expiry for display // Format expiry for display
expiresAtStr := "Never" expiresAtStr := "Never"
@@ -173,16 +125,55 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
GeneratedURL: generatedURL, GeneratedURL: generatedURL,
ExpiresAt: expiresAtStr, ExpiresAt: expiresAtStr,
FormURL: sourceURL, FormURL: sourceURL,
FormWidth: widthStr, FormWidth: r.FormValue("width"),
FormHeight: heightStr, FormHeight: r.FormValue("height"),
FormFormat: format, FormFormat: r.FormValue("format"),
FormQuality: qualityStr, FormQuality: r.FormValue("quality"),
FormFit: fit, FormFit: r.FormValue("fit"),
FormTTL: ttlStr, FormTTL: r.FormValue("ttl"),
}) })
} }
} }
// buildGeneratePayload parses the numeric form fields and assembles the
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0).
func buildGeneratePayload(
parsed *url.URL, form url.Values,
) (*encurl.Payload, time.Time, int) {
width, _ := strconv.Atoi(form.Get("width"))
height, _ := strconv.Atoi(form.Get("height"))
quality, _ := strconv.Atoi(form.Get("quality"))
ttl, _ := strconv.Atoi(form.Get("ttl"))
if quality <= 0 {
quality = 85
}
var (
expiresAt time.Time
expiresAtUnix int64
)
if ttl > 0 {
expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
expiresAtUnix = expiresAt.Unix()
}
payload := &encurl.Payload{
SourceHost: parsed.Host,
SourcePath: parsed.Path,
SourceQuery: parsed.RawQuery,
Width: width,
Height: height,
Format: imgcache.ImageFormat(form.Get("format")),
Quality: quality,
FitMode: imgcache.FitMode(form.Get("fit")),
ExpiresAt: expiresAtUnix,
}
return payload, expiresAt, ttl
}
// generatorData holds template data for the generator page. // generatorData holds template data for the generator page.
type generatorData struct { type generatorData struct {
GeneratedURL string GeneratedURL string
@@ -206,7 +197,8 @@ func (s *Handlers) renderLogin(w http.ResponseWriter, errorMsg string) {
Error: errorMsg, Error: errorMsg,
} }
if err := templates.Render(w, "login.html", data); err != nil { err := templates.Render(w, "login.html", data)
if err != nil {
s.log.Error("failed to render login template", "error", err) s.log.Error("failed to render login template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
} }
@@ -219,13 +211,16 @@ func (s *Handlers) renderGenerator(w http.ResponseWriter, data *generatorData) {
data = &generatorData{} data = &generatorData{}
} }
if err := templates.Render(w, "generator.html", data); err != nil { err := templates.Render(w, "generator.html", data)
if err != nil {
s.log.Error("failed to render generator template", "error", err) s.log.Error("failed to render generator template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
} }
} }
func (s *Handlers) renderGeneratorWithForm(w http.ResponseWriter, errorMsg string, form url.Values) { func (s *Handlers) renderGeneratorWithForm(
w http.ResponseWriter, errorMsg string, form url.Values,
) {
s.renderGenerator(w, &generatorData{ s.renderGenerator(w, &generatorData{
Error: errorMsg, Error: errorMsg,
FormURL: form.Get("url"), FormURL: form.Get("url"),
@@ -237,3 +232,19 @@ func (s *Handlers) renderGeneratorWithForm(w http.ResponseWriter, errorMsg strin
FormTTL: form.Get("ttl"), FormTTL: form.Get("ttl"),
}) })
} }
func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) string {
// Build full URL (URL-encode the token for safety)
scheme := "https"
if s.config.Debug {
scheme = "http"
}
// Determine file extension for the trailing filename
ext := format
if ext == "" || ext == "orig" {
ext = "jpg" // Default extension
}
return scheme + "://" + r.Host + "/v1/e/" + url.PathEscape(token) + "/img." + ext
}

View File

@@ -22,6 +22,7 @@ import (
// Params defines dependencies for Handlers. // Params defines dependencies for Handlers.
type Params struct { type Params struct {
fx.In fx.In
Logger *logger.Logger Logger *logger.Logger
Healthcheck *healthcheck.Healthcheck Healthcheck *healthcheck.Healthcheck
Database *database.Database Database *database.Database
@@ -75,6 +76,7 @@ func (s *Handlers) initImageService() error {
// Create the fetcher config // Create the fetcher config
fetcherCfg := httpfetcher.DefaultConfig() fetcherCfg := httpfetcher.DefaultConfig()
fetcherCfg.AllowHTTP = s.config.AllowHTTP fetcherCfg.AllowHTTP = s.config.AllowHTTP
if s.config.UpstreamConnectionsPerHost > 0 { if s.config.UpstreamConnectionsPerHost > 0 {
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
} }
@@ -100,6 +102,7 @@ func (s *Handlers) initImageService() error {
if err != nil { if err != nil {
return err return err
} }
s.sessMgr = sessMgr s.sessMgr = sessMgr
// Initialize encrypted URL generator // Initialize encrypted URL generator
@@ -107,6 +110,7 @@ func (s *Handlers) initImageService() error {
if err != nil { if err != nil {
return err return err
} }
s.encGen = encGen s.encGen = encGen
s.log.Info("session manager and URL generator initialized") s.log.Info("session manager and URL generator initialized")
@@ -114,9 +118,10 @@ func (s *Handlers) initImageService() error {
return nil return nil
} }
func (s *Handlers) respondJSON(w http.ResponseWriter, data interface{}, status int) { func (s *Handlers) respondJSON(w http.ResponseWriter, data any, status int) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
if data != nil { if data != nil {
err := json.NewEncoder(w).Encode(data) err := json.NewEncoder(w).Encode(data)
if err != nil { if err != nil {
@@ -126,7 +131,7 @@ func (s *Handlers) respondJSON(w http.ResponseWriter, data interface{}, status i
} }
func (s *Handlers) respondError(w http.ResponseWriter, message string, status int) { func (s *Handlers) respondError(w http.ResponseWriter, message string, status int) {
s.respondJSON(w, map[string]interface{}{ s.respondJSON(w, map[string]any{
"error": message, "error": message,
"status": status, "status": status,
"timestamp": time.Now().UTC().Format(time.RFC3339), "timestamp": time.Now().UTC().Format(time.RFC3339),

View File

@@ -83,7 +83,8 @@ func setupTestDB(t *testing.T) *sql.DB {
t.Fatalf("failed to open test db: %v", err) t.Fatalf("failed to open test db: %v", err)
} }
if err := database.ApplyMigrations(context.Background(), db, nil); err != nil { err = database.ApplyMigrations(context.Background(), db, nil)
if err != nil {
t.Fatalf("failed to apply migrations: %v", err) t.Fatalf("failed to apply migrations: %v", err)
} }
@@ -94,14 +95,16 @@ func generateTestJPEG(t *testing.T, width, height int, c color.Color) []byte {
t.Helper() t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height)) img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ { for y := range height {
for x := 0; x < width; x++ { for x := range width {
img.Set(x, y, c) img.Set(x, y, c)
} }
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85})
if err != nil {
t.Fatalf("failed to encode test JPEG: %v", err) t.Fatalf("failed to encode test JPEG: %v", err)
} }
@@ -117,7 +120,9 @@ func newMockFetcher(fs fs.FS) *mockFetcher {
return &mockFetcher{fs: fs} return &mockFetcher{fs: fs}
} }
func (f *mockFetcher) Fetch(ctx context.Context, url string) (*httpfetcher.FetchResult, error) { func (f *mockFetcher) Fetch(
_ context.Context, url string,
) (*httpfetcher.FetchResult, error) {
// Remove https:// prefix // Remove https:// prefix
path := url[8:] // Remove "https://" path := url[8:] // Remove "https://"
@@ -134,13 +139,16 @@ func (f *mockFetcher) Fetch(ctx context.Context, url string) (*httpfetcher.Fetch
} }
func TestHandleImage_HEAD_ReturnsHeadersOnly(t *testing.T) { func TestHandleImage_HEAD_ReturnsHeadersOnly(t *testing.T) {
t.Parallel()
fix := setupTestHandler(t) fix := setupTestHandler(t)
// Create a chi router to properly handle wildcards // Create a chi router to properly handle wildcards
r := chi.NewRouter() r := chi.NewRouter()
r.Head("/v1/image/*", fix.handler.HandleImage()) r.Head("/v1/image/*", fix.handler.HandleImage())
req := httptest.NewRequest(http.MethodHead, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodHead,
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
r.ServeHTTP(rec, req) r.ServeHTTP(rec, req)
@@ -167,13 +175,16 @@ func TestHandleImage_HEAD_ReturnsHeadersOnly(t *testing.T) {
} }
func TestHandleImage_ConditionalRequest_IfNoneMatch_Returns304(t *testing.T) { func TestHandleImage_ConditionalRequest_IfNoneMatch_Returns304(t *testing.T) {
t.Parallel()
fix := setupTestHandler(t) fix := setupTestHandler(t)
r := chi.NewRouter() r := chi.NewRouter()
r.Get("/v1/image/*", fix.handler.HandleImage()) r.Get("/v1/image/*", fix.handler.HandleImage())
// First request to get the ETag // First request to get the ETag
req1 := httptest.NewRequest(http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil) req1 := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
rec1 := httptest.NewRecorder() rec1 := httptest.NewRecorder()
r.ServeHTTP(rec1, req1) r.ServeHTTP(rec1, req1)
@@ -188,15 +199,18 @@ func TestHandleImage_ConditionalRequest_IfNoneMatch_Returns304(t *testing.T) {
} }
// Second request with If-None-Match header // Second request with If-None-Match header
req2 := httptest.NewRequest(http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil) req2 := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
req2.Header.Set("If-None-Match", etag) req2.Header.Set("If-None-Match", etag)
rec2 := httptest.NewRecorder() rec2 := httptest.NewRecorder()
r.ServeHTTP(rec2, req2) r.ServeHTTP(rec2, req2)
// Should return 304 Not Modified // Should return 304 Not Modified
if rec2.Code != http.StatusNotModified { if rec2.Code != http.StatusNotModified {
t.Errorf("Conditional request status = %d, want %d", rec2.Code, http.StatusNotModified) t.Errorf("Conditional request status = %d, want %d",
rec2.Code, http.StatusNotModified)
} }
// Body should be empty for 304 response // Body should be empty for 304 response
@@ -206,21 +220,26 @@ func TestHandleImage_ConditionalRequest_IfNoneMatch_Returns304(t *testing.T) {
} }
func TestHandleImage_ConditionalRequest_IfNoneMatch_DifferentETag(t *testing.T) { func TestHandleImage_ConditionalRequest_IfNoneMatch_DifferentETag(t *testing.T) {
t.Parallel()
fix := setupTestHandler(t) fix := setupTestHandler(t)
r := chi.NewRouter() r := chi.NewRouter()
r.Get("/v1/image/*", fix.handler.HandleImage()) r.Get("/v1/image/*", fix.handler.HandleImage())
// Request with non-matching ETag // Request with non-matching ETag
req := httptest.NewRequest(http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
req.Header.Set("If-None-Match", `"different-etag"`) req.Header.Set("If-None-Match", `"different-etag"`)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
r.ServeHTTP(rec, req) r.ServeHTTP(rec, req)
// Should return 200 OK with full response // Should return 200 OK with full response
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Errorf("Request with non-matching ETag status = %d, want %d", rec.Code, http.StatusOK) t.Errorf("Request with non-matching ETag status = %d, want %d",
rec.Code, http.StatusOK)
} }
// Body should not be empty // Body should not be empty
@@ -230,12 +249,15 @@ func TestHandleImage_ConditionalRequest_IfNoneMatch_DifferentETag(t *testing.T)
} }
func TestHandleImage_ETagHeader(t *testing.T) { func TestHandleImage_ETagHeader(t *testing.T) {
t.Parallel()
fix := setupTestHandler(t) fix := setupTestHandler(t)
r := chi.NewRouter() r := chi.NewRouter()
r.Get("/v1/image/*", fix.handler.HandleImage()) r.Get("/v1/image/*", fix.handler.HandleImage())
req := httptest.NewRequest(http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
r.ServeHTTP(rec, req) r.ServeHTTP(rec, req)

View File

@@ -16,64 +16,14 @@ import (
// /v1/image/<host>/<path>/<width>x<height>.<format> // /v1/image/<host>/<path>/<width>x<height>.<format>
func (s *Handlers) HandleImage() http.HandlerFunc { func (s *Handlers) HandleImage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() req, ok := s.parseImageRequest(w, r)
if !ok {
// Get the wildcard path from chi
pathParam := chi.URLParam(r, "*")
// Parse the URL path
parsed, err := imgcache.ParseImagePath(pathParam)
if err != nil {
s.log.Warn("failed to parse image URL",
"path", pathParam,
"error", err,
)
s.respondError(w, "invalid image URL: "+err.Error(), http.StatusBadRequest)
return return
} }
// Convert to ImageRequest
req := parsed.ToImageRequest()
// Parse signature params from query string
query := r.URL.Query()
req.Signature = query.Get("sig")
if expStr := query.Get("exp"); expStr != "" {
if exp, err := strconv.ParseInt(expStr, 10, 64); err == nil {
req.Expires = time.Unix(exp, 0)
}
}
// Parse optional quality and fit params
if qStr := query.Get("q"); qStr != "" {
if q, err := strconv.Atoi(qStr); err == nil && q > 0 && q <= 100 {
req.Quality = q
}
}
if fit := query.Get("fit"); fit != "" {
req.FitMode = imgcache.FitMode(fit)
if err := imgcache.ValidateFitMode(req.FitMode); err != nil {
s.respondError(w, "invalid fit mode: "+fit, http.StatusBadRequest)
return
}
}
// Default quality if not set
if req.Quality == 0 {
req.Quality = 85
}
// Default fit mode if not set
if req.FitMode == "" {
req.FitMode = imgcache.FitCover
}
// Validate signature if required // Validate signature if required
if err := s.imgSvc.ValidateRequest(req); err != nil { err := s.imgSvc.ValidateRequest(req)
if err != nil {
s.log.Warn("signature validation failed", s.log.Warn("signature validation failed",
"host", req.SourceHost, "host", req.SourceHost,
"path", req.SourcePath, "path", req.SourcePath,
@@ -89,83 +39,17 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
// Get the image (from cache or fetch/process) // Get the image (from cache or fetch/process)
startTime := time.Now() startTime := time.Now()
resp, err := s.imgSvc.Get(ctx, req)
resp, err := s.imgSvc.Get(r.Context(), req)
if err != nil { if err != nil {
s.log.Error("failed to get image", s.respondImageError(w, req, err)
"host", req.SourceHost,
"path", req.SourcePath,
"error", err,
)
// Check for specific error types
if errors.Is(err, httpfetcher.ErrSSRFBlocked) {
s.respondError(w, "forbidden", http.StatusForbidden)
return
}
if errors.Is(err, httpfetcher.ErrUpstreamError) {
s.respondError(w, "upstream error", http.StatusBadGateway)
return
}
s.respondError(w, "internal error", http.StatusInternalServerError)
return return
} }
defer func() { _ = resp.Content.Close() }() defer func() { _ = resp.Content.Close() }()
// Set response headers s.writeImageResponse(w, r, req, resp, cacheKey, startTime)
w.Header().Set("Content-Type", resp.ContentType)
if resp.ContentLength > 0 {
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
}
// Cache control headers
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
if resp.ETag != "" {
w.Header().Set("ETag", resp.ETag)
// Check for conditional request (If-None-Match)
if ifNoneMatch := r.Header.Get("If-None-Match"); ifNoneMatch != "" {
if ifNoneMatch == resp.ETag {
w.WriteHeader(http.StatusNotModified)
return
}
}
}
// Handle HEAD request - return headers only
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
// Stream the response
w.WriteHeader(http.StatusOK)
servedBytes, err := io.Copy(w, resp.Content)
if err != nil {
s.log.Error("failed to write response",
"error", err,
)
}
// Log cache status and timing after serving
duration := time.Since(startTime)
s.log.Info("image served",
"cache_key", cacheKey,
"cache_status", resp.CacheStatus,
"duration_ms", duration.Milliseconds(),
"format", req.Format,
"served_bytes", servedBytes,
"fetched_bytes", resp.FetchedBytes,
)
} }
} }
@@ -180,3 +64,156 @@ func (s *Handlers) HandleRobotsTxt() http.HandlerFunc {
_, _ = w.Write(robotsTxt) _, _ = w.Write(robotsTxt)
} }
} }
// parseImageRequest parses the wildcard path and query parameters into
// an ImageRequest. On invalid input it writes an error response and
// returns false.
func (s *Handlers) parseImageRequest(
w http.ResponseWriter, r *http.Request,
) (*imgcache.ImageRequest, bool) {
// Get the wildcard path from chi
pathParam := chi.URLParam(r, "*")
// Parse the URL path
parsed, err := imgcache.ParseImagePath(pathParam)
if err != nil {
s.log.Warn("failed to parse image URL",
"path", pathParam,
"error", err,
)
s.respondError(w, "invalid image URL: "+err.Error(), http.StatusBadRequest)
return nil, false
}
// Convert to ImageRequest
req := parsed.ToImageRequest()
// Parse signature params from query string
query := r.URL.Query()
req.Signature = query.Get("sig")
if expStr := query.Get("exp"); expStr != "" {
exp, parseErr := strconv.ParseInt(expStr, 10, 64)
if parseErr == nil {
req.Expires = time.Unix(exp, 0)
}
}
// Parse optional quality and fit params
if qStr := query.Get("q"); qStr != "" {
q, parseErr := strconv.Atoi(qStr)
if parseErr == nil && q > 0 && q <= 100 {
req.Quality = q
}
}
if fit := query.Get("fit"); fit != "" {
req.FitMode = imgcache.FitMode(fit)
fitErr := imgcache.ValidateFitMode(req.FitMode)
if fitErr != nil {
s.respondError(w, "invalid fit mode: "+fit, http.StatusBadRequest)
return nil, false
}
}
// Default quality if not set
if req.Quality == 0 {
req.Quality = 85
}
// Default fit mode if not set
if req.FitMode == "" {
req.FitMode = imgcache.FitCover
}
return req, true
}
// respondImageError maps image retrieval errors to HTTP responses.
func (s *Handlers) respondImageError(
w http.ResponseWriter, req *imgcache.ImageRequest, err error,
) {
s.log.Error("failed to get image",
"host", req.SourceHost,
"path", req.SourcePath,
"error", err,
)
// Check for specific error types
if errors.Is(err, httpfetcher.ErrSSRFBlocked) {
s.respondError(w, "forbidden", http.StatusForbidden)
return
}
if errors.Is(err, httpfetcher.ErrUpstreamError) {
s.respondError(w, "upstream error", http.StatusBadGateway)
return
}
s.respondError(w, "internal error", http.StatusInternalServerError)
}
// writeImageResponse writes headers and streams the image content,
// handling conditional and HEAD requests.
func (s *Handlers) writeImageResponse(
w http.ResponseWriter, r *http.Request,
req *imgcache.ImageRequest, resp *imgcache.ImageResponse,
cacheKey imgcache.VariantKey, startTime time.Time,
) {
// Set response headers
w.Header().Set("Content-Type", resp.ContentType)
if resp.ContentLength > 0 {
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
}
// Cache control headers
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
if resp.ETag != "" {
w.Header().Set("ETag", resp.ETag)
// Check for conditional request (If-None-Match)
if ifNoneMatch := r.Header.Get("If-None-Match"); ifNoneMatch != "" {
if ifNoneMatch == resp.ETag {
w.WriteHeader(http.StatusNotModified)
return
}
}
}
// Handle HEAD request - return headers only
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
// Stream the response
w.WriteHeader(http.StatusOK)
servedBytes, err := io.Copy(w, resp.Content)
if err != nil {
s.log.Error("failed to write response",
"error", err,
)
}
// Log cache status and timing after serving
duration := time.Since(startTime)
s.log.Info("image served",
"cache_key", cacheKey,
"cache_status", resp.CacheStatus,
"duration_ms", duration.Milliseconds(),
"format", req.Format,
"served_bytes", servedBytes,
"fetched_bytes", resp.FetchedBytes,
)
}

View File

@@ -15,8 +15,9 @@ import (
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imgcache"
) )
// HandleImageEnc handles requests to /v1/e/{token}/* for encrypted image URLs. // HandleImageEnc handles requests to /v1/e/{token}/* for encrypted
// The trailing path (e.g., /img.jpg) is ignored but helps browsers identify the content type. // image URLs. The trailing path (e.g., /img.jpg) is ignored but helps
// browsers identify the content type.
func (s *Handlers) HandleImageEnc() http.HandlerFunc { func (s *Handlers) HandleImageEnc() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
@@ -57,7 +58,8 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
"format", req.Format, "format", req.Format,
) )
// Fetch and process the image (no signature validation needed - encrypted URL is trusted) // Fetch and process the image (no signature validation
// needed - encrypted URL is trusted)
resp, err := s.imgSvc.Get(ctx, req) resp, err := s.imgSvc.Get(ctx, req)
if err != nil { if err != nil {
s.handleImageError(w, err) s.handleImageError(w, err)
@@ -68,6 +70,7 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
// Set response headers // Set response headers
w.Header().Set("Content-Type", resp.ContentType) w.Header().Set("Content-Type", resp.ContentType)
if resp.ContentLength > 0 { if resp.ContentLength > 0 {
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10)) w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
} }

View File

@@ -16,6 +16,7 @@ import (
// Params defines dependencies for Healthcheck. // Params defines dependencies for Healthcheck.
type Params struct { type Params struct {
fx.In fx.In
Globals *globals.Globals Globals *globals.Globals
Config *config.Config Config *config.Config
Logger *logger.Logger Logger *logger.Logger
@@ -53,6 +54,8 @@ func New(lc fx.Lifecycle, params Params) (*Healthcheck, error) {
} }
// Response is the JSON response for health checks. // Response is the JSON response for health checks.
//
//nolint:tagliatelle // health endpoint response format uses snake_case
type Response struct { type Response struct {
Status string `json:"status"` Status string `json:"status"`
Now string `json:"now"` Now string `json:"now"`
@@ -63,10 +66,6 @@ type Response struct {
Maintenance bool `json:"maintenance_mode"` Maintenance bool `json:"maintenance_mode"`
} }
func (s *Healthcheck) uptime() time.Duration {
return time.Since(s.StartupTime)
}
// Healthcheck returns the current health status. // Healthcheck returns the current health status.
func (s *Healthcheck) Healthcheck() *Response { func (s *Healthcheck) Healthcheck() *Response {
resp := &Response{ resp := &Response{
@@ -81,3 +80,7 @@ func (s *Healthcheck) Healthcheck() *Response {
return resp return resp
} }
func (s *Healthcheck) uptime() time.Duration {
return time.Since(s.StartupTime)
}

View File

@@ -12,6 +12,7 @@ import (
"net/http" "net/http"
"net/http/httptrace" "net/http/httptrace"
neturl "net/url" neturl "net/url"
"slices"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -28,6 +29,23 @@ const (
DefaultMaxConnectionsPerHost = 20 DefaultMaxConnectionsPerHost = 20
) )
// MIME content types.
const (
contentTypeJPEG = "image/jpeg"
contentTypePNG = "image/png"
contentTypeGIF = "image/gif"
contentTypeWebP = "image/webp"
contentTypeAVIF = "image/avif"
contentTypeSVG = "image/svg+xml"
contentTypeOctetStream = "application/octet-stream"
)
// Loopback addresses blocked by SSRF protection.
const (
localhostIPv4 = "127.0.0.1"
localhostIPv6 = "::1"
)
// Fetcher errors. // Fetcher errors.
var ( var (
ErrSSRFBlocked = errors.New("request blocked: private or internal IP") ErrSSRFBlocked = errors.New("request blocked: private or internal IP")
@@ -39,6 +57,12 @@ var (
ErrUpstreamTimeout = errors.New("upstream request timeout") ErrUpstreamTimeout = errors.New("upstream request timeout")
) )
// Internal fetcher errors.
var (
errTooManyRedirects = errors.New("too many redirects")
errConnectFailed = errors.New("failed to connect")
)
// Fetcher retrieves content from upstream origins. // Fetcher retrieves content from upstream origins.
type Fetcher interface { type Fetcher interface {
// Fetch retrieves content from the given URL. // Fetch retrieves content from the given URL.
@@ -92,12 +116,12 @@ func DefaultConfig() *Config {
MaxResponseSize: DefaultMaxResponseSize, MaxResponseSize: DefaultMaxResponseSize,
UserAgent: "pixa/1.0", UserAgent: "pixa/1.0",
AllowedContentTypes: []string{ AllowedContentTypes: []string{
"image/jpeg", contentTypeJPEG,
"image/png", contentTypePNG,
"image/gif", contentTypeGIF,
"image/webp", contentTypeWebP,
"image/avif", contentTypeAVIF,
"image/svg+xml", contentTypeSVG,
}, },
AllowHTTP: false, AllowHTTP: false,
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost, MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
@@ -132,10 +156,12 @@ func New(config *Config) *HTTPFetcher {
// Don't follow redirects automatically - we need to validate each hop // Don't follow redirects automatically - we need to validate each hop
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= DefaultMaxRedirects { if len(via) >= DefaultMaxRedirects {
return errors.New("too many redirects") return errTooManyRedirects
} }
// Validate the redirect target // Validate the redirect target
if err := validateURL(req.URL.String(), config.AllowHTTP); err != nil { err := validateURL(req.Context(), req.URL.String(), config.AllowHTTP)
if err != nil {
return fmt.Errorf("redirect blocked: %w", err) return fmt.Errorf("redirect blocked: %w", err)
} }
@@ -150,24 +176,11 @@ func New(config *Config) *HTTPFetcher {
} }
} }
// getHostSemaphore returns the semaphore for a host, creating it if necessary.
func (f *HTTPFetcher) getHostSemaphore(host string) chan struct{} {
f.hostSemMu.Lock()
defer f.hostSemMu.Unlock()
sem, ok := f.hostSems[host]
if !ok {
sem = make(chan struct{}, f.config.MaxConnectionsPerHost)
f.hostSems[host] = sem
}
return sem
}
// Fetch retrieves content from the given URL with SSRF protection. // Fetch retrieves content from the given URL with SSRF protection.
func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) { func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
// Validate URL before making request // Validate URL before making request
if err := validateURL(url, f.config.AllowHTTP); err != nil { err := validateURL(ctx, url, f.config.AllowHTTP)
if err != nil {
return nil, err return nil, err
} }
@@ -201,7 +214,6 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
URL: parsedURL, URL: parsedURL,
Header: make(http.Header), Header: make(http.Header),
} }
req = req.WithContext(ctx)
req.Header.Set("User-Agent", f.config.UserAgent) req.Header.Set("User-Agent", f.config.UserAgent)
req.Header.Set("Accept", strings.Join(f.config.AllowedContentTypes, ", ")) req.Header.Set("Accept", strings.Join(f.config.AllowedContentTypes, ", "))
@@ -216,11 +228,10 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
} }
}, },
} }
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) req = req.WithContext(httptrace.WithClientTrace(ctx, trace))
startTime := time.Now() startTime := time.Now()
//nolint:gosec // G704: URL validated by validateURL() above
resp, err := f.client.Do(req) resp, err := f.client.Do(req)
fetchDuration := time.Since(startTime) fetchDuration := time.Since(startTime)
@@ -233,6 +244,39 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
return nil, fmt.Errorf("upstream request failed: %w", err) return nil, fmt.Errorf("upstream request failed: %w", err)
} }
result, err := f.buildResult(resp, remoteAddr, fetchDuration, sem)
if err != nil {
return nil, err
}
// Mark success so defer doesn't release the semaphore
success = true
return result, nil
}
// getHostSemaphore returns the semaphore for a host, creating it if necessary.
func (f *HTTPFetcher) getHostSemaphore(host string) chan struct{} {
f.hostSemMu.Lock()
defer f.hostSemMu.Unlock()
sem, ok := f.hostSems[host]
if !ok {
sem = make(chan struct{}, f.config.MaxConnectionsPerHost)
f.hostSems[host] = sem
}
return sem
}
// buildResult validates the upstream response and assembles a FetchResult
// whose Content releases the host semaphore slot when closed.
func (f *HTTPFetcher) buildResult(
resp *http.Response,
remoteAddr string,
fetchDuration time.Duration,
sem chan struct{},
) (*FetchResult, error) {
// Extract HTTP version (strip "HTTP/" prefix) // Extract HTTP version (strip "HTTP/" prefix)
httpVersion := strings.TrimPrefix(resp.Proto, "HTTP/") httpVersion := strings.TrimPrefix(resp.Proto, "HTTP/")
@@ -265,9 +309,6 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
remaining: f.config.MaxResponseSize, remaining: f.config.MaxResponseSize,
} }
// Mark success so defer doesn't release the semaphore
success = true
return &FetchResult{ return &FetchResult{
Content: &semaphoreReleasingReadCloser{limitedBody, resp.Body, sem}, Content: &semaphoreReleasingReadCloser{limitedBody, resp.Body, sem},
ContentLength: resp.ContentLength, ContentLength: resp.ContentLength,
@@ -297,7 +338,7 @@ func (f *HTTPFetcher) isAllowedContentType(contentType string) bool {
} }
// validateURL checks if a URL is safe to fetch (not internal/private). // validateURL checks if a URL is safe to fetch (not internal/private).
func validateURL(rawURL string, allowHTTP bool) error { func validateURL(ctx context.Context, rawURL string, allowHTTP bool) error {
if !allowHTTP && !strings.HasPrefix(rawURL, "https://") { if !allowHTTP && !strings.HasPrefix(rawURL, "https://") {
return ErrUnsupportedScheme return ErrUnsupportedScheme
} }
@@ -309,7 +350,8 @@ func validateURL(rawURL string, allowHTTP bool) error {
} }
// Remove port if present // Remove port if present
if h, _, err := net.SplitHostPort(host); err == nil { h, _, err := net.SplitHostPort(host)
if err == nil {
host = h host = h
} }
@@ -319,15 +361,16 @@ func validateURL(rawURL string, allowHTTP bool) error {
} }
// Resolve the host to check IP addresses // Resolve the host to check IP addresses
ips, err := net.LookupIP(host) addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil { if err != nil {
return fmt.Errorf("%w: %s", ErrInvalidHost, host) return fmt.Errorf("%w: %s", ErrInvalidHost, host)
} }
for _, ip := range ips { private := slices.ContainsFunc(addrs, func(addr net.IPAddr) bool {
if isPrivateIP(ip) { return isPrivateIP(addr.IP)
return ErrSSRFBlocked })
} if private {
return ErrSSRFBlocked
} }
return nil return nil
@@ -340,9 +383,11 @@ func extractHost(rawURL string) string {
if idx := strings.Index(url, "://"); idx != -1 { if idx := strings.Index(url, "://"); idx != -1 {
url = url[idx+3:] url = url[idx+3:]
} }
if idx := strings.Index(url, "/"); idx != -1 { if idx := strings.Index(url, "/"); idx != -1 {
url = url[:idx] url = url[:idx]
} }
if idx := strings.Index(url, "?"); idx != -1 { if idx := strings.Index(url, "?"); idx != -1 {
url = url[:idx] url = url[:idx]
} }
@@ -355,8 +400,8 @@ func isLocalhost(host string) bool {
host = strings.ToLower(host) host = strings.ToLower(host)
return host == "localhost" || return host == "localhost" ||
host == "127.0.0.1" || host == localhostIPv4 ||
host == "::1" || host == localhostIPv6 ||
host == "[::1]" || host == "[::1]" ||
strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".localhost") ||
strings.HasSuffix(host, ".local") strings.HasSuffix(host, ".local")
@@ -422,23 +467,23 @@ func ssrfSafeDialer(ctx context.Context, network, addr string) (net.Conn, error)
} }
// Check all resolved IPs // Check all resolved IPs
for _, ip := range ips { if slices.ContainsFunc(ips, isPrivateIP) {
if isPrivateIP(ip) { return nil, ErrSSRFBlocked
return nil, ErrSSRFBlocked
}
} }
// Connect using the first valid IP // Connect using the first valid IP
var dialer net.Dialer var dialer net.Dialer
for _, ip := range ips { for _, ip := range ips {
addr := net.JoinHostPort(ip.String(), port) addr := net.JoinHostPort(ip.String(), port)
conn, err := dialer.DialContext(ctx, network, addr) conn, err := dialer.DialContext(ctx, network, addr)
if err == nil { if err == nil {
return conn, nil return conn, nil
} }
} }
return nil, fmt.Errorf("failed to connect to %s", host) return nil, fmt.Errorf("%w to %s", errConnectFailed, host)
} }
// limitedReader wraps a reader and limits the number of bytes read. // limitedReader wraps a reader and limits the number of bytes read.
@@ -465,6 +510,7 @@ func (r *limitedReader) Read(p []byte) (int, error) {
// semaphoreReleasingReadCloser releases a semaphore slot when closed. // semaphoreReleasingReadCloser releases a semaphore slot when closed.
type semaphoreReleasingReadCloser struct { type semaphoreReleasingReadCloser struct {
*limitedReader *limitedReader
closer io.Closer closer io.Closer
sem chan struct{} sem chan struct{}
} }

View File

@@ -9,7 +9,12 @@ import (
"testing/fstest" "testing/fstest"
) )
// testHost is the hostname used by mock fetch tests.
const testHost = "example.com"
func TestDefaultConfig(t *testing.T) { func TestDefaultConfig(t *testing.T) {
t.Parallel()
cfg := DefaultConfig() cfg := DefaultConfig()
if cfg.Timeout != DefaultFetchTimeout { if cfg.Timeout != DefaultFetchTimeout {
@@ -35,6 +40,8 @@ func TestDefaultConfig(t *testing.T) {
} }
func TestNewWithNilConfigUsesDefaults(t *testing.T) { func TestNewWithNilConfigUsesDefaults(t *testing.T) {
t.Parallel()
f := New(nil) f := New(nil)
if f == nil { if f == nil {
@@ -51,24 +58,28 @@ func TestNewWithNilConfigUsesDefaults(t *testing.T) {
} }
func TestIsAllowedContentType(t *testing.T) { func TestIsAllowedContentType(t *testing.T) {
t.Parallel()
f := New(DefaultConfig()) f := New(DefaultConfig())
tests := []struct { tests := []struct {
contentType string contentType string
want bool want bool
}{ }{
{"image/jpeg", true}, {contentTypeJPEG, true},
{"image/png", true}, {contentTypePNG, true},
{"image/webp", true}, {contentTypeWebP, true},
{"image/jpeg; charset=utf-8", true}, {"image/jpeg; charset=utf-8", true},
{"IMAGE/JPEG", true}, {"IMAGE/JPEG", true},
{"text/html", false}, {"text/html", false},
{"application/octet-stream", false}, {contentTypeOctetStream, false},
{"", false}, {"", false},
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.contentType, func(t *testing.T) { t.Run(tc.contentType, func(t *testing.T) {
t.Parallel()
got := f.isAllowedContentType(tc.contentType) got := f.isAllowedContentType(tc.contentType)
if got != tc.want { if got != tc.want {
t.Errorf("isAllowedContentType(%q) = %v, want %v", tc.contentType, got, tc.want) t.Errorf("isAllowedContentType(%q) = %v, want %v", tc.contentType, got, tc.want)
@@ -78,20 +89,24 @@ func TestIsAllowedContentType(t *testing.T) {
} }
func TestExtractHost(t *testing.T) { func TestExtractHost(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
url string url string
want string want string
}{ }{
{"https://example.com/path", "example.com"}, {"https://example.com/path", testHost},
{"http://example.com:8080/path", "example.com:8080"}, {"http://example.com:8080/path", "example.com:8080"},
{"https://example.com", "example.com"}, {"https://example.com", testHost},
{"https://example.com?q=1", "example.com"}, {"https://example.com?q=1", testHost},
{"example.com/path", "example.com"}, {"example.com/path", testHost},
{"", ""}, {"", ""},
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.url, func(t *testing.T) { t.Run(tc.url, func(t *testing.T) {
t.Parallel()
got := extractHost(tc.url) got := extractHost(tc.url)
if got != tc.want { if got != tc.want {
t.Errorf("extractHost(%q) = %q, want %q", tc.url, got, tc.want) t.Errorf("extractHost(%q) = %q, want %q", tc.url, got, tc.want)
@@ -101,23 +116,27 @@ func TestExtractHost(t *testing.T) {
} }
func TestIsLocalhost(t *testing.T) { func TestIsLocalhost(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
host string host string
want bool want bool
}{ }{
{"localhost", true}, {"localhost", true},
{"LOCALHOST", true}, {"LOCALHOST", true},
{"127.0.0.1", true}, {localhostIPv4, true},
{"::1", true}, {localhostIPv6, true},
{"[::1]", true}, {"[::1]", true},
{"foo.localhost", true}, {"foo.localhost", true},
{"foo.local", true}, {"foo.local", true},
{"example.com", false}, {testHost, false},
{"127.0.0.2", false}, // Handled by isPrivateIP, not isLocalhost string match {"127.0.0.2", false}, // Handled by isPrivateIP, not isLocalhost string match
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.host, func(t *testing.T) { t.Run(tc.host, func(t *testing.T) {
t.Parallel()
got := isLocalhost(tc.host) got := isLocalhost(tc.host)
if got != tc.want { if got != tc.want {
t.Errorf("isLocalhost(%q) = %v, want %v", tc.host, got, tc.want) t.Errorf("isLocalhost(%q) = %v, want %v", tc.host, got, tc.want)
@@ -127,18 +146,20 @@ func TestIsLocalhost(t *testing.T) {
} }
func TestIsPrivateIP(t *testing.T) { func TestIsPrivateIP(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
ip string ip string
want bool want bool
}{ }{
{"127.0.0.1", true}, // loopback {localhostIPv4, true}, // loopback
{"10.0.0.1", true}, // private {"10.0.0.1", true}, // private
{"192.168.1.1", true}, // private {"192.168.1.1", true}, // private
{"172.16.0.1", true}, // private {"172.16.0.1", true}, // private
{"169.254.1.1", true}, // link-local {"169.254.1.1", true}, // link-local
{"0.0.0.0", true}, // unspecified {"0.0.0.0", true}, // unspecified
{"224.0.0.1", true}, // multicast {"224.0.0.1", true}, // multicast
{"::1", true}, // IPv6 loopback {localhostIPv6, true}, // IPv6 loopback
{"fe80::1", true}, // IPv6 link-local {"fe80::1", true}, // IPv6 link-local
{"8.8.8.8", false}, // public {"8.8.8.8", false}, // public
{"2001:4860:4860::8888", false}, // public IPv6 {"2001:4860:4860::8888", false}, // public IPv6
@@ -146,6 +167,8 @@ func TestIsPrivateIP(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.ip, func(t *testing.T) { t.Run(tc.ip, func(t *testing.T) {
t.Parallel()
ip := net.ParseIP(tc.ip) ip := net.ParseIP(tc.ip)
if ip == nil { if ip == nil {
t.Fatalf("failed to parse IP %q", tc.ip) t.Fatalf("failed to parse IP %q", tc.ip)
@@ -164,15 +187,19 @@ func TestIsPrivateIP(t *testing.T) {
} }
func TestValidateURL_RejectsNonHTTPS(t *testing.T) { func TestValidateURL_RejectsNonHTTPS(t *testing.T) {
err := validateURL("http://example.com/path", false) t.Parallel()
err := validateURL(t.Context(), "http://example.com/path", false)
if !errors.Is(err, ErrUnsupportedScheme) { if !errors.Is(err, ErrUnsupportedScheme) {
t.Errorf("validateURL http = %v, want ErrUnsupportedScheme", err) t.Errorf("validateURL http = %v, want ErrUnsupportedScheme", err)
} }
} }
func TestValidateURL_AllowsHTTPWhenConfigured(t *testing.T) { func TestValidateURL_AllowsHTTPWhenConfigured(t *testing.T) {
t.Parallel()
// Use a host that won't resolve (explicit .invalid TLD) so we don't hit DNS. // Use a host that won't resolve (explicit .invalid TLD) so we don't hit DNS.
err := validateURL("http://nonexistent.invalid/path", true) err := validateURL(t.Context(), "http://nonexistent.invalid/path", true)
// We expect a host resolution error, not ErrUnsupportedScheme. // We expect a host resolution error, not ErrUnsupportedScheme.
if errors.Is(err, ErrUnsupportedScheme) { if errors.Is(err, ErrUnsupportedScheme) {
t.Error("validateURL with AllowHTTP should not return ErrUnsupportedScheme") t.Error("validateURL with AllowHTTP should not return ErrUnsupportedScheme")
@@ -180,20 +207,26 @@ func TestValidateURL_AllowsHTTPWhenConfigured(t *testing.T) {
} }
func TestValidateURL_RejectsLocalhost(t *testing.T) { func TestValidateURL_RejectsLocalhost(t *testing.T) {
err := validateURL("https://localhost/path", false) t.Parallel()
err := validateURL(t.Context(), "https://localhost/path", false)
if !errors.Is(err, ErrSSRFBlocked) { if !errors.Is(err, ErrSSRFBlocked) {
t.Errorf("validateURL localhost = %v, want ErrSSRFBlocked", err) t.Errorf("validateURL localhost = %v, want ErrSSRFBlocked", err)
} }
} }
func TestValidateURL_EmptyHost(t *testing.T) { func TestValidateURL_EmptyHost(t *testing.T) {
err := validateURL("https:///path", false) t.Parallel()
err := validateURL(t.Context(), "https:///path", false)
if !errors.Is(err, ErrInvalidHost) { if !errors.Is(err, ErrInvalidHost) {
t.Errorf("validateURL empty host = %v, want ErrInvalidHost", err) t.Errorf("validateURL empty host = %v, want ErrInvalidHost", err)
} }
} }
func TestMockFetcher_FetchesFile(t *testing.T) { func TestMockFetcher_FetchesFile(t *testing.T) {
t.Parallel()
mockFS := fstest.MapFS{ mockFS := fstest.MapFS{
"example.com/images/photo.jpg": &fstest.MapFile{Data: []byte("fake-jpeg-data")}, "example.com/images/photo.jpg": &fstest.MapFile{Data: []byte("fake-jpeg-data")},
} }
@@ -206,7 +239,7 @@ func TestMockFetcher_FetchesFile(t *testing.T) {
} }
defer func() { _ = result.Content.Close() }() defer func() { _ = result.Content.Close() }()
if result.ContentType != "image/jpeg" { if result.ContentType != contentTypeJPEG {
t.Errorf("ContentType = %q, want image/jpeg", result.ContentType) t.Errorf("ContentType = %q, want image/jpeg", result.ContentType)
} }
@@ -225,6 +258,8 @@ func TestMockFetcher_FetchesFile(t *testing.T) {
} }
func TestMockFetcher_MissingFileReturnsUpstreamError(t *testing.T) { func TestMockFetcher_MissingFileReturnsUpstreamError(t *testing.T) {
t.Parallel()
mockFS := fstest.MapFS{} mockFS := fstest.MapFS{}
m := NewMock(mockFS) m := NewMock(mockFS)
@@ -235,6 +270,8 @@ func TestMockFetcher_MissingFileReturnsUpstreamError(t *testing.T) {
} }
func TestMockFetcher_RespectsContextCancellation(t *testing.T) { func TestMockFetcher_RespectsContextCancellation(t *testing.T) {
t.Parallel()
mockFS := fstest.MapFS{ mockFS := fstest.MapFS{
"example.com/photo.jpg": &fstest.MapFile{Data: []byte("data")}, "example.com/photo.jpg": &fstest.MapFile{Data: []byte("data")},
} }
@@ -250,24 +287,28 @@ func TestMockFetcher_RespectsContextCancellation(t *testing.T) {
} }
func TestDetectContentTypeFromPath(t *testing.T) { func TestDetectContentTypeFromPath(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
path string path string
want string want string
}{ }{
{"foo/bar.jpg", "image/jpeg"}, {"foo/bar.jpg", contentTypeJPEG},
{"foo/bar.JPG", "image/jpeg"}, {"foo/bar.JPG", contentTypeJPEG},
{"foo/bar.jpeg", "image/jpeg"}, {"foo/bar.jpeg", contentTypeJPEG},
{"foo/bar.png", "image/png"}, {"foo/bar.png", contentTypePNG},
{"foo/bar.gif", "image/gif"}, {"foo/bar.gif", contentTypeGIF},
{"foo/bar.webp", "image/webp"}, {"foo/bar.webp", contentTypeWebP},
{"foo/bar.avif", "image/avif"}, {"foo/bar.avif", contentTypeAVIF},
{"foo/bar.svg", "image/svg+xml"}, {"foo/bar.svg", contentTypeSVG},
{"foo/bar.bin", "application/octet-stream"}, {"foo/bar.bin", contentTypeOctetStream},
{"foo/bar", "application/octet-stream"}, {"foo/bar", contentTypeOctetStream},
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.path, func(t *testing.T) { t.Run(tc.path, func(t *testing.T) {
t.Parallel()
got := detectContentTypeFromPath(tc.path) got := detectContentTypeFromPath(tc.path)
if got != tc.want { if got != tc.want {
t.Errorf("detectContentTypeFromPath(%q) = %q, want %q", tc.path, got, tc.want) t.Errorf("detectContentTypeFromPath(%q) = %q, want %q", tc.path, got, tc.want)
@@ -277,6 +318,8 @@ func TestDetectContentTypeFromPath(t *testing.T) {
} }
func TestLimitedReader_EnforcesLimit(t *testing.T) { func TestLimitedReader_EnforcesLimit(t *testing.T) {
t.Parallel()
src := make([]byte, 100) src := make([]byte, 100)
r := &limitedReader{ r := &limitedReader{
reader: &byteReader{data: src}, reader: &byteReader{data: src},
@@ -298,10 +341,11 @@ func TestLimitedReader_EnforcesLimit(t *testing.T) {
total := n total := n
for total < 50 { for total < 50 {
nn, err := r.Read(buf) nn, err := r.Read(buf)
total += nn
if err != nil { if err != nil {
t.Fatalf("during drain: %v", err) t.Fatalf("during drain: %v", err)
} }
total += nn
} }
// Now the limit is exhausted — next read should error. // Now the limit is exhausted — next read should error.

View File

@@ -4,12 +4,14 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"net/http" "net/http"
"strings" "strings"
) )
// errEmptyURLPath is returned when a mock URL has no usable path.
var errEmptyURLPath = errors.New("empty URL path")
// MockFetcher implements Fetcher using an embedded filesystem. // MockFetcher implements Fetcher using an embedded filesystem.
// Files are organized as: hostname/path/to/file.ext // Files are organized as: hostname/path/to/file.ext
// URLs like https://example.com/images/photo.jpg map to example.com/images/photo.jpg. // URLs like https://example.com/images/photo.jpg map to example.com/images/photo.jpg.
@@ -59,7 +61,7 @@ func (m *MockFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
contentType := detectContentTypeFromPath(path) contentType := detectContentTypeFromPath(path)
return &FetchResult{ return &FetchResult{
Content: f.(io.ReadCloser), Content: f,
ContentLength: stat.Size(), ContentLength: stat.Size(),
ContentType: contentType, ContentType: contentType,
Headers: make(http.Header), Headers: make(http.Header),
@@ -86,7 +88,7 @@ func urlToFSPath(rawURL string) (string, error) {
} }
if url == "" { if url == "" {
return "", errors.New("empty URL path") return "", errEmptyURLPath
} }
return url, nil return url, nil
@@ -98,18 +100,18 @@ func detectContentTypeFromPath(path string) string {
switch { switch {
case strings.HasSuffix(path, ".jpg"), strings.HasSuffix(path, ".jpeg"): case strings.HasSuffix(path, ".jpg"), strings.HasSuffix(path, ".jpeg"):
return "image/jpeg" return contentTypeJPEG
case strings.HasSuffix(path, ".png"): case strings.HasSuffix(path, ".png"):
return "image/png" return contentTypePNG
case strings.HasSuffix(path, ".gif"): case strings.HasSuffix(path, ".gif"):
return "image/gif" return contentTypeGIF
case strings.HasSuffix(path, ".webp"): case strings.HasSuffix(path, ".webp"):
return "image/webp" return contentTypeWebP
case strings.HasSuffix(path, ".avif"): case strings.HasSuffix(path, ".avif"):
return "image/avif" return contentTypeAVIF
case strings.HasSuffix(path, ".svg"): case strings.HasSuffix(path, ".svg"):
return "image/svg+xml" return contentTypeSVG
default: default:
return "application/octet-stream" return contentTypeOctetStream
} }
} }

View File

@@ -13,7 +13,9 @@ import (
) )
// vipsOnce ensures vips is initialized exactly once. // vipsOnce ensures vips is initialized exactly once.
var vipsOnce sync.Once //nolint:gochecknoglobals // package-level sync.Once for one-time vips init //
//nolint:gochecknoglobals // package-level sync.Once for one-time vips init
var vipsOnce sync.Once
// initVips initializes libvips with quiet logging. // initVips initializes libvips with quiet logging.
func initVips() { func initVips() {
@@ -96,10 +98,12 @@ const DefaultMaxInputBytes = 50 << 20
// ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension. // ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension.
var ErrInputTooLarge = errors.New("input image dimensions exceed maximum") var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
// ErrInputDataTooLarge is returned when the raw input data exceeds the configured byte limit. // ErrInputDataTooLarge is returned when the raw input data exceeds the
// configured byte limit.
var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size") var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
// ErrUnsupportedOutputFormat is returned when the requested output format is not supported. // ErrUnsupportedOutputFormat is returned when the requested output format is
// not supported.
var ErrUnsupportedOutputFormat = errors.New("unsupported output format") var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
// ImageProcessor implements image transformation using libvips via govips. // ImageProcessor implements image transformation using libvips via govips.
@@ -170,25 +174,12 @@ func (p *ImageProcessor) Process(
} }
// Determine target dimensions // Determine target dimensions
targetWidth := req.Size.Width targetWidth, targetHeight := targetDimensions(req.Size, origWidth, origHeight)
targetHeight := req.Size.Height
// Handle dimension calculation
if targetWidth == 0 && targetHeight == 0 {
// Both are 0: keep original size
targetWidth = origWidth
targetHeight = origHeight
} else if targetWidth == 0 {
// Only height specified: calculate width proportionally
targetWidth = origWidth * targetHeight / origHeight
} else if targetHeight == 0 {
// Only width specified: calculate height proportionally
targetHeight = origHeight * targetWidth / origWidth
}
// Resize if needed // Resize if needed
if targetWidth != origWidth || targetHeight != origHeight { if targetWidth != origWidth || targetHeight != origHeight {
if err := p.resize(img, targetWidth, targetHeight, req.FitMode); err != nil { err := p.resize(img, targetWidth, targetHeight, req.FitMode)
if err != nil {
return nil, fmt.Errorf("failed to resize: %w", err) return nil, fmt.Errorf("failed to resize: %w", err)
} }
} }
@@ -217,14 +208,42 @@ func (p *ImageProcessor) Process(
}, nil }, nil
} }
// targetDimensions calculates the output dimensions for a requested size,
// scaling proportionally when only one dimension is given and keeping the
// original dimensions when both are zero.
func targetDimensions(size Size, origWidth, origHeight int) (int, int) {
switch {
case size.Width == 0 && size.Height == 0:
// Both are 0: keep original size
return origWidth, origHeight
case size.Width == 0:
// Only height specified: calculate width proportionally
return origWidth * size.Height / origHeight, size.Height
case size.Height == 0:
// Only width specified: calculate height proportionally
return size.Width, origHeight * size.Width / origWidth
default:
return size.Width, size.Height
}
}
// MIME types for the supported image formats.
const (
mimeJPEG = "image/jpeg"
mimePNG = "image/png"
mimeGIF = "image/gif"
mimeWebP = "image/webp"
mimeAVIF = "image/avif"
)
// SupportedInputFormats returns MIME types this processor can read. // SupportedInputFormats returns MIME types this processor can read.
func (p *ImageProcessor) SupportedInputFormats() []string { func (p *ImageProcessor) SupportedInputFormats() []string {
return []string{ return []string{
"image/jpeg", mimeJPEG,
"image/png", mimePNG,
"image/gif", mimeGIF,
"image/webp", mimeWebP,
"image/avif", mimeAVIF,
} }
} }
@@ -243,15 +262,17 @@ func (p *ImageProcessor) SupportedOutputFormats() []Format {
func FormatToMIME(format Format) string { func FormatToMIME(format Format) string {
switch format { switch format {
case FormatJPEG: case FormatJPEG:
return "image/jpeg" return mimeJPEG
case FormatPNG: case FormatPNG:
return "image/png" return mimePNG
case FormatWebP: case FormatWebP:
return "image/webp" return mimeWebP
case FormatGIF: case FormatGIF:
return "image/gif" return mimeGIF
case FormatAVIF: case FormatAVIF:
return "image/avif" return mimeAVIF
case FormatOriginal:
return "application/octet-stream"
default: default:
return "application/octet-stream" return "application/octet-stream"
} }
@@ -270,14 +291,20 @@ func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
case vips.ImageTypeWEBP: case vips.ImageTypeWEBP:
return "webp" return "webp"
case vips.ImageTypeAVIF, vips.ImageTypeHEIF: case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
return "avif" return string(FormatAVIF)
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
vips.ImageTypeJP2K, vips.ImageTypeJXL:
return "unknown"
default: default:
return "unknown" return "unknown"
} }
} }
// resize resizes the image according to the fit mode. // resize resizes the image according to the fit mode.
func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMode) error { func (p *ImageProcessor) resize(
img *vips.ImageRef, width, height int, fit FitMode,
) error {
switch fit { switch fit {
case FitCover, "": case FitCover, "":
// Resize and crop to fill exact dimensions (default) // Resize and crop to fill exact dimensions (default)
@@ -303,6 +330,7 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
if img.Width() <= width && img.Height() <= height { if img.Width() <= width && img.Height() <= height {
return nil // Already fits return nil // Already fits
} }
imgW, imgH := img.Width(), img.Height() imgW, imgH := img.Width(), img.Height()
scaleW := float64(width) / float64(imgW) scaleW := float64(width) / float64(imgW)
scaleH := float64(height) / float64(imgH) scaleH := float64(height) / float64(imgH)
@@ -331,7 +359,9 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
const defaultQuality = 85 const defaultQuality = 85
// encode encodes an image to the specified format. // encode encodes an image to the specified format.
func (p *ImageProcessor) encode(img *vips.ImageRef, format Format, quality int) ([]byte, error) { func (p *ImageProcessor) encode(
img *vips.ImageRef, format Format, quality int,
) ([]byte, error) {
if quality <= 0 { if quality <= 0 {
quality = defaultQuality quality = defaultQuality
} }
@@ -367,8 +397,11 @@ func (p *ImageProcessor) encode(img *vips.ImageRef, format Format, quality int)
Quality: quality, Quality: quality,
} }
case FormatOriginal:
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
default: default:
return nil, fmt.Errorf("unsupported output format: %s", format) return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
} }
output, _, err := img.Export(&params) output, _, err := img.Export(&params)
@@ -390,7 +423,7 @@ func (p *ImageProcessor) formatFromString(format string) Format {
return FormatGIF return FormatGIF
case "webp": case "webp":
return FormatWebP return FormatWebP
case "avif": case string(FormatAVIF):
return FormatAVIF return FormatAVIF
default: default:
return FormatJPEG return FormatJPEG

View File

@@ -3,6 +3,7 @@ package imageprocessor
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"image" "image"
"image/color" "image/color"
"image/jpeg" "image/jpeg"
@@ -16,7 +17,9 @@ import (
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
initVips() initVips()
code := m.Run() code := m.Run()
vips.Shutdown() vips.Shutdown()
os.Exit(code) os.Exit(code)
} }
@@ -27,11 +30,11 @@ func createTestJPEG(t *testing.T, width, height int) []byte {
img := image.NewRGBA(image.Rect(0, 0, width, height)) img := image.NewRGBA(image.Rect(0, 0, width, height))
// Fill with a gradient // Fill with a gradient
for y := 0; y < height; y++ { for y := range height {
for x := 0; x < width; x++ { for x := range width {
img.Set(x, y, color.RGBA{ img.Set(x, y, color.RGBA{
R: uint8(x * 255 / width), R: uint8((x * 255 / width) & 0xff),
G: uint8(y * 255 / height), G: uint8((y * 255 / height) & 0xff),
B: 128, B: 128,
A: 255, A: 255,
}) })
@@ -39,7 +42,9 @@ func createTestJPEG(t *testing.T, width, height int) []byte {
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90})
if err != nil {
t.Fatalf("failed to encode test JPEG: %v", err) t.Fatalf("failed to encode test JPEG: %v", err)
} }
@@ -51,11 +56,11 @@ func createTestPNG(t *testing.T, width, height int) []byte {
t.Helper() t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height)) img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ { for y := range height {
for x := 0; x < width; x++ { for x := range width {
img.Set(x, y, color.RGBA{ img.Set(x, y, color.RGBA{
R: uint8(x * 255 / width), R: uint8((x * 255 / width) & 0xff),
G: uint8(y * 255 / height), G: uint8((y * 255 / height) & 0xff),
B: 128, B: 128,
A: 255, A: 255,
}) })
@@ -63,37 +68,54 @@ func createTestPNG(t *testing.T, width, height int) []byte {
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
err := png.Encode(&buf, img)
if err != nil {
t.Fatalf("failed to encode test PNG: %v", err) t.Fatalf("failed to encode test PNG: %v", err)
} }
return buf.Bytes() return buf.Bytes()
} }
// isAVIF reports whether data starts with an AVIF ftyp box.
func isAVIF(data []byte) bool {
if len(data) < 12 || string(data[4:8]) != "ftyp" {
return false
}
brand := string(data[8:12])
return brand == string(FormatAVIF) || brand == "avis"
}
// detectMIME is a minimal magic-byte detector for test assertions. // detectMIME is a minimal magic-byte detector for test assertions.
func detectMIME(data []byte) string { func detectMIME(data []byte) string {
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return "image/jpeg" return mimeJPEG
} }
if len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n" { if len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n" {
return "image/png" return mimePNG
} }
if len(data) >= 4 && string(data[:4]) == "GIF8" { if len(data) >= 4 && string(data[:4]) == "GIF8" {
return "image/gif" return mimeGIF
} }
if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" { if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
return "image/webp" return mimeWebP
} }
if len(data) >= 12 && string(data[4:8]) == "ftyp" {
brand := string(data[8:12]) if isAVIF(data) {
if brand == "avif" || brand == "avis" { return mimeAVIF
return "image/avif"
}
} }
return "" return ""
} }
func TestImageProcessor_ResizeJPEG(t *testing.T) { func TestImageProcessor_ResizeJPEG(t *testing.T) {
t.Parallel()
proc := New(Params{}) proc := New(Params{})
ctx := context.Background() ctx := context.Background()
@@ -110,7 +132,8 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Process() error = %v", err) t.Fatalf("Process() error = %v", err)
} }
defer result.Content.Close()
defer func() { _ = result.Content.Close() }()
if result.Width != 400 { if result.Width != 400 {
t.Errorf("Process() width = %d, want 400", result.Width) t.Errorf("Process() width = %d, want 400", result.Width)
@@ -131,12 +154,14 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
} }
mime := detectMIME(data) mime := detectMIME(data)
if mime != "image/jpeg" { if mime != mimeJPEG {
t.Errorf("Output format = %v, want image/jpeg", mime) t.Errorf("Output format = %v, want image/jpeg", mime)
} }
} }
func TestImageProcessor_ConvertToPNG(t *testing.T) { func TestImageProcessor_ConvertToPNG(t *testing.T) {
t.Parallel()
proc := New(Params{}) proc := New(Params{})
ctx := context.Background() ctx := context.Background()
@@ -152,7 +177,8 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Process() error = %v", err) t.Fatalf("Process() error = %v", err)
} }
defer result.Content.Close()
defer func() { _ = result.Content.Close() }()
data, err := io.ReadAll(result.Content) data, err := io.ReadAll(result.Content)
if err != nil { if err != nil {
@@ -160,19 +186,25 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
} }
mime := detectMIME(data) mime := detectMIME(data)
if mime != "image/png" { if mime != mimePNG {
t.Errorf("Output format = %v, want image/png", mime) t.Errorf("Output format = %v, want image/png", mime)
} }
} }
func TestImageProcessor_OriginalSize(t *testing.T) { // processAndCheckSize processes a test JPEG of the given input dimensions
// with the requested size and asserts the resulting dimensions.
func processAndCheckSize(
t *testing.T, inputW, inputH int, size Size, wantW, wantH int,
) {
t.Helper()
proc := New(Params{}) proc := New(Params{})
ctx := context.Background() ctx := context.Background()
input := createTestJPEG(t, 640, 480) input := createTestJPEG(t, inputW, inputH)
req := &Request{ req := &Request{
Size: Size{Width: 0, Height: 0}, // Original size Size: size,
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
FitMode: FitCover, FitMode: FitCover,
@@ -182,18 +214,28 @@ func TestImageProcessor_OriginalSize(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Process() error = %v", err) t.Fatalf("Process() error = %v", err)
} }
defer result.Content.Close()
if result.Width != 640 { defer func() { _ = result.Content.Close() }()
t.Errorf("Process() width = %d, want 640", result.Width)
if result.Width != wantW {
t.Errorf("Process() width = %d, want %d", result.Width, wantW)
} }
if result.Height != 480 { if result.Height != wantH {
t.Errorf("Process() height = %d, want 480", result.Height) t.Errorf("Process() height = %d, want %d", result.Height, wantH)
} }
} }
func TestImageProcessor_OriginalSize(t *testing.T) {
t.Parallel()
// Width and height 0: keep original size
processAndCheckSize(t, 640, 480, Size{Width: 0, Height: 0}, 640, 480)
}
func TestImageProcessor_FitContain(t *testing.T) { func TestImageProcessor_FitContain(t *testing.T) {
t.Parallel()
proc := New(Params{}) proc := New(Params{})
ctx := context.Background() ctx := context.Background()
@@ -212,7 +254,8 @@ func TestImageProcessor_FitContain(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Process() error = %v", err) t.Fatalf("Process() error = %v", err)
} }
defer result.Content.Close()
defer func() { _ = result.Content.Close() }()
// With contain, the image should fit within the box // With contain, the image should fit within the box
if result.Width > 400 || result.Height > 400 { if result.Width > 400 || result.Height > 400 {
@@ -221,66 +264,24 @@ func TestImageProcessor_FitContain(t *testing.T) {
} }
func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) { func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
proc := New(Params{}) t.Parallel()
ctx := context.Background()
// 800x600 image, request width=400 height=0 // 800x600 image, request width=400 height=0
// Should scale proportionally to 400x300 // Should scale proportionally to 400x300
input := createTestJPEG(t, 800, 600) processAndCheckSize(t, 800, 600, Size{Width: 400, Height: 0}, 400, 300)
req := &Request{
Size: Size{Width: 400, Height: 0},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer result.Content.Close()
if result.Width != 400 {
t.Errorf("Process() width = %d, want 400", result.Width)
}
if result.Height != 300 {
t.Errorf("Process() height = %d, want 300", result.Height)
}
} }
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) { func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
proc := New(Params{}) t.Parallel()
ctx := context.Background()
// 800x600 image, request width=0 height=300 // 800x600 image, request width=0 height=300
// Should scale proportionally to 400x300 // Should scale proportionally to 400x300
input := createTestJPEG(t, 800, 600) processAndCheckSize(t, 800, 600, Size{Width: 0, Height: 300}, 400, 300)
req := &Request{
Size: Size{Width: 0, Height: 300},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer result.Content.Close()
if result.Width != 400 {
t.Errorf("Process() width = %d, want 400", result.Width)
}
if result.Height != 300 {
t.Errorf("Process() height = %d, want 300", result.Height)
}
} }
func TestImageProcessor_ProcessPNG(t *testing.T) { func TestImageProcessor_ProcessPNG(t *testing.T) {
t.Parallel()
proc := New(Params{}) proc := New(Params{})
ctx := context.Background() ctx := context.Background()
@@ -296,7 +297,8 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Process() error = %v", err) t.Fatalf("Process() error = %v", err)
} }
defer result.Content.Close()
defer func() { _ = result.Content.Close() }()
if result.Width != 200 { if result.Width != 200 {
t.Errorf("Process() width = %d, want 200", result.Width) t.Errorf("Process() width = %d, want 200", result.Width)
@@ -308,6 +310,8 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
} }
func TestImageProcessor_SupportedFormats(t *testing.T) { func TestImageProcessor_SupportedFormats(t *testing.T) {
t.Parallel()
proc := New(Params{}) proc := New(Params{})
inputFormats := proc.SupportedInputFormats() inputFormats := proc.SupportedInputFormats()
@@ -322,55 +326,49 @@ func TestImageProcessor_SupportedFormats(t *testing.T) {
} }
func TestImageProcessor_RejectsOversizedInput(t *testing.T) { func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
proc := New(Params{}) t.Parallel()
ctx := context.Background()
// Create an image that exceeds MaxInputDimension (e.g., 10000x100) // Images exceeding MaxInputDimension in either dimension must be
// This should be rejected before processing to prevent DoS // rejected before processing to prevent DoS.
input := createTestJPEG(t, 10000, 100) tests := []struct {
name string
req := &Request{ width int
Size: Size{Width: 100, Height: 100}, height int
Format: FormatJPEG, }{
Quality: 85, {name: "oversized width", width: 10000, height: 100},
FitMode: FitCover, {name: "oversized height", width: 100, height: 10000},
} }
_, err := proc.Process(ctx, bytes.NewReader(input), req) for _, tt := range tests {
if err == nil { t.Run(tt.name, func(t *testing.T) {
t.Error("Process() should reject oversized input images") t.Parallel()
}
if err != ErrInputTooLarge { proc := New(Params{})
t.Errorf("Process() error = %v, want ErrInputTooLarge", err) ctx := context.Background()
} input := createTestJPEG(t, tt.width, tt.height)
}
func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) { req := &Request{
proc := New(Params{}) Size: Size{Width: 100, Height: 100},
ctx := context.Background() Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
// Create an image with oversized height _, err := proc.Process(ctx, bytes.NewReader(input), req)
input := createTestJPEG(t, 100, 10000) if err == nil {
t.Error("Process() should reject oversized input images")
}
req := &Request{ if !errors.Is(err, ErrInputTooLarge) {
Size: Size{Width: 100, Height: 100}, t.Errorf("Process() error = %v, want ErrInputTooLarge", err)
Format: FormatJPEG, }
Quality: 85, })
FitMode: FitCover,
}
_, err := proc.Process(ctx, bytes.NewReader(input), req)
if err == nil {
t.Error("Process() should reject oversized input images")
}
if err != ErrInputTooLarge {
t.Errorf("Process() error = %v, want ErrInputTooLarge", err)
} }
} }
func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) { func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
t.Parallel()
proc := New(Params{}) proc := New(Params{})
ctx := context.Background() ctx := context.Background()
@@ -386,12 +384,20 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
result, err := proc.Process(ctx, bytes.NewReader(input), req) result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil { if err != nil {
t.Fatalf("Process() should accept images at MaxInputDimension, got error: %v", err) t.Fatalf(
"Process() should accept images at MaxInputDimension, got error: %v",
err,
)
} }
defer result.Content.Close()
defer func() { _ = result.Content.Close() }()
} }
func TestImageProcessor_EncodeWebP(t *testing.T) { // encodeAndCheck processes a 200x150 test JPEG into a 100x75 output of the
// given format and asserts the output MIME type and dimensions.
func encodeAndCheck(t *testing.T, format Format, quality int, wantMIME string) {
t.Helper()
proc := New(Params{}) proc := New(Params{})
ctx := context.Background() ctx := context.Background()
@@ -399,8 +405,8 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
req := &Request{ req := &Request{
Size: Size{Width: 100, Height: 75}, Size: Size{Width: 100, Height: 75},
Format: FormatWebP, Format: format,
Quality: 80, Quality: quality,
FitMode: FitCover, FitMode: FitCover,
} }
@@ -408,29 +414,39 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Process() error = %v, want nil", err) t.Fatalf("Process() error = %v, want nil", err)
} }
defer result.Content.Close()
// Verify output is valid WebP defer func() { _ = result.Content.Close() }()
// Verify output format
data, err := io.ReadAll(result.Content) data, err := io.ReadAll(result.Content)
if err != nil { if err != nil {
t.Fatalf("failed to read result: %v", err) t.Fatalf("failed to read result: %v", err)
} }
mime := detectMIME(data) mime := detectMIME(data)
if mime != "image/webp" { if mime != wantMIME {
t.Errorf("Output format = %v, want image/webp", mime) t.Errorf("Output format = %v, want %v", mime, wantMIME)
} }
// Verify dimensions // Verify dimensions
if result.Width != 100 { if result.Width != 100 {
t.Errorf("Width = %d, want 100", result.Width) t.Errorf("Width = %d, want 100", result.Width)
} }
if result.Height != 75 { if result.Height != 75 {
t.Errorf("Height = %d, want 75", result.Height) t.Errorf("Height = %d, want 75", result.Height)
} }
} }
func TestImageProcessor_EncodeWebP(t *testing.T) {
t.Parallel()
encodeAndCheck(t, FormatWebP, 80, mimeWebP)
}
func TestImageProcessor_DecodeAVIF(t *testing.T) { func TestImageProcessor_DecodeAVIF(t *testing.T) {
t.Parallel()
proc := New(Params{}) proc := New(Params{})
ctx := context.Background() ctx := context.Background()
@@ -452,7 +468,8 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Process() error = %v, want nil (AVIF decoding should work)", err) t.Fatalf("Process() error = %v, want nil (AVIF decoding should work)", err)
} }
defer result.Content.Close()
defer func() { _ = result.Content.Close() }()
// Verify output is valid JPEG // Verify output is valid JPEG
data, err := io.ReadAll(result.Content) data, err := io.ReadAll(result.Content)
@@ -461,14 +478,17 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
} }
mime := detectMIME(data) mime := detectMIME(data)
if mime != "image/jpeg" { if mime != mimeJPEG {
t.Errorf("Output format = %v, want image/jpeg", mime) t.Errorf("Output format = %v, want image/jpeg", mime)
} }
} }
func TestImageProcessor_RejectsOversizedInputData(t *testing.T) { func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
t.Parallel()
// Create a processor with a very small byte limit // Create a processor with a very small byte limit
const limit = 1024 const limit = 1024
proc := New(Params{MaxInputBytes: limit}) proc := New(Params{MaxInputBytes: limit})
ctx := context.Background() ctx := context.Background()
@@ -490,12 +510,14 @@ func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
t.Fatal("Process() should reject input exceeding maxInputBytes") t.Fatal("Process() should reject input exceeding maxInputBytes")
} }
if err != ErrInputDataTooLarge { if !errors.Is(err, ErrInputDataTooLarge) {
t.Errorf("Process() error = %v, want ErrInputDataTooLarge", err) t.Errorf("Process() error = %v, want ErrInputDataTooLarge", err)
} }
} }
func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) { func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
t.Parallel()
// Create a small image and set limit well above its size // Create a small image and set limit well above its size
input := createTestJPEG(t, 10, 10) input := createTestJPEG(t, 10, 10)
limit := int64(len(input)) * 10 // 10× headroom limit := int64(len(input)) * 10 // 10× headroom
@@ -514,10 +536,13 @@ func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Process() error = %v, want nil", err) t.Fatalf("Process() error = %v, want nil", err)
} }
defer result.Content.Close()
defer func() { _ = result.Content.Close() }()
} }
func TestImageProcessor_DefaultMaxInputBytes(t *testing.T) { func TestImageProcessor_DefaultMaxInputBytes(t *testing.T) {
t.Parallel()
// Passing 0 should use the default // Passing 0 should use the default
proc := New(Params{}) proc := New(Params{})
if proc.maxInputBytes != DefaultMaxInputBytes { if proc.maxInputBytes != DefaultMaxInputBytes {
@@ -532,40 +557,7 @@ func TestImageProcessor_DefaultMaxInputBytes(t *testing.T) {
} }
func TestImageProcessor_EncodeAVIF(t *testing.T) { func TestImageProcessor_EncodeAVIF(t *testing.T) {
proc := New(Params{}) t.Parallel()
ctx := context.Background()
input := createTestJPEG(t, 200, 150) encodeAndCheck(t, FormatAVIF, 85, mimeAVIF)
req := &Request{
Size: Size{Width: 100, Height: 75},
Format: FormatAVIF,
Quality: 85,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v, want nil (AVIF encoding should work)", err)
}
defer result.Content.Close()
// Verify output is valid AVIF
data, err := io.ReadAll(result.Content)
if err != nil {
t.Fatalf("failed to read result: %v", err)
}
mime := detectMIME(data)
if mime != "image/avif" {
t.Errorf("Output format = %v, want image/avif", mime)
}
// Verify dimensions
if result.Width != 100 {
t.Errorf("Width = %d, want 100", result.Width)
}
if result.Height != 75 {
t.Errorf("Height = %d, want 75", result.Height)
}
} }

View File

@@ -43,23 +43,30 @@ type Cache struct {
srcMetadata *MetadataStorage // source metadata by host/path srcMetadata *MetadataStorage // source metadata by host/path
config CacheConfig config CacheConfig
// In-memory cache of variant metadata (content type, size) to avoid reading .meta files // In-memory cache of variant metadata (content type, size) to avoid
// reading .meta files
metaCache map[VariantKey]variantMeta metaCache map[VariantKey]variantMeta
} }
// NewCache creates a new cache instance. // NewCache creates a new cache instance.
func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) { func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
srcContent, err := NewContentStorage(filepath.Join(config.StateDir, "cache", "sources")) srcContent, err := NewContentStorage(
filepath.Join(config.StateDir, "cache", "sources"),
)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create source content storage: %w", err) return nil, fmt.Errorf("failed to create source content storage: %w", err)
} }
variants, err := NewVariantStorage(filepath.Join(config.StateDir, "cache", "variants")) variants, err := NewVariantStorage(
filepath.Join(config.StateDir, "cache", "variants"),
)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create variant storage: %w", err) return nil, fmt.Errorf("failed to create variant storage: %w", err)
} }
srcMetadata, err := NewMetadataStorage(filepath.Join(config.StateDir, "cache", "metadata")) srcMetadata, err := NewMetadataStorage(
filepath.Join(config.StateDir, "cache", "metadata"),
)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create source metadata storage: %w", err) return nil, fmt.Errorf("failed to create source metadata storage: %w", err)
} }
@@ -123,7 +130,11 @@ func (c *Cache) StoreSource(
// Store in database // Store in database
pathHash := HashPath(req.SourcePath + "?" + req.SourceQuery) pathHash := HashPath(req.SourcePath + "?" + req.SourceQuery)
headersJSON, _ := json.Marshal(result.Headers)
headersJSON, err := json.Marshal(result.Headers)
if err != nil {
return "", fmt.Errorf("failed to marshal response headers: %w", err)
}
_, err = c.db.ExecContext(ctx, ` _, err = c.db.ExecContext(ctx, `
INSERT INTO source_content (content_hash, content_type, size_bytes) INSERT INTO source_content (content_hash, content_type, size_bytes)
@@ -166,16 +177,16 @@ func (c *Cache) StoreSource(
RemoteAddr: result.RemoteAddr, RemoteAddr: result.RemoteAddr,
} }
if err := c.srcMetadata.Store(req.SourceHost, pathHash, meta); err != nil { // A failure here is non-fatal; the metadata is in the database.
// Non-fatal, we have it in the database _ = c.srcMetadata.Store(req.SourceHost, pathHash, meta)
_ = err
}
return contentHash, nil return contentHash, nil
} }
// StoreVariant stores a processed variant by its cache key. // StoreVariant stores a processed variant by its cache key.
func (c *Cache) StoreVariant(cacheKey VariantKey, content io.Reader, contentType string) error { func (c *Cache) StoreVariant(
cacheKey VariantKey, content io.Reader, contentType string,
) error {
_, err := c.variants.Store(cacheKey, content, contentType) _, err := c.variants.Store(cacheKey, content, contentType)
return err return err
@@ -183,7 +194,9 @@ func (c *Cache) StoreVariant(cacheKey VariantKey, content io.Reader, contentType
// LookupSource checks if we have cached source content for a request. // LookupSource checks if we have cached source content for a request.
// Returns the content hash and content type if found, or empty values if not. // Returns the content hash and content type if found, or empty values if not.
func (c *Cache) LookupSource(ctx context.Context, req *ImageRequest) (ContentHash, string, error) { func (c *Cache) LookupSource(
ctx context.Context, req *ImageRequest,
) (ContentHash, string, error) {
var hashStr, contentType string var hashStr, contentType string
err := c.db.QueryRowContext(ctx, ` err := c.db.QueryRowContext(ctx, `
@@ -210,11 +223,15 @@ func (c *Cache) LookupSource(ctx context.Context, req *ImageRequest) (ContentHas
} }
// StoreNegative stores a negative cache entry for a failed fetch. // StoreNegative stores a negative cache entry for a failed fetch.
func (c *Cache) StoreNegative(ctx context.Context, req *ImageRequest, statusCode int, errMsg string) error { func (c *Cache) StoreNegative(
ctx context.Context, req *ImageRequest, statusCode int, errMsg string,
) error {
expiresAt := time.Now().UTC().Add(c.config.NegativeTTL) expiresAt := time.Now().UTC().Add(c.config.NegativeTTL)
_, err := c.db.ExecContext(ctx, ` _, err := c.db.ExecContext(ctx, `
INSERT INTO negative_cache (source_host, source_path, source_query, status_code, error_message, expires_at) INSERT INTO negative_cache
(source_host, source_path, source_query, status_code,
error_message, expires_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(source_host, source_path, source_query) DO UPDATE SET ON CONFLICT(source_host, source_path, source_query) DO UPDATE SET
status_code = excluded.status_code, status_code = excluded.status_code,
@@ -229,46 +246,16 @@ func (c *Cache) StoreNegative(ctx context.Context, req *ImageRequest, statusCode
return nil return nil
} }
// checkNegativeCache checks if a request is in the negative cache.
func (c *Cache) checkNegativeCache(ctx context.Context, req *ImageRequest) (bool, error) {
var expiresAt time.Time
err := c.db.QueryRowContext(ctx, `
SELECT expires_at FROM negative_cache
WHERE source_host = ? AND source_path = ? AND source_query = ?
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("failed to check negative cache: %w", err)
}
// Check if expired
if time.Now().After(expiresAt) {
// Clean up expired entry
_, _ = c.db.ExecContext(ctx, `
DELETE FROM negative_cache
WHERE source_host = ? AND source_path = ? AND source_query = ?
`, req.SourceHost, req.SourcePath, req.SourceQuery)
return false, nil
}
return true, nil
}
// GetSourceMetadataID returns the source metadata ID for a request. // GetSourceMetadataID returns the source metadata ID for a request.
func (c *Cache) GetSourceMetadataID(ctx context.Context, req *ImageRequest) (int64, error) { func (c *Cache) GetSourceMetadataID(
ctx context.Context, req *ImageRequest,
) (int64, error) {
var id int64 var id int64
err := c.db.QueryRowContext(ctx, ` err := c.db.QueryRowContext(ctx, `
SELECT id FROM source_metadata SELECT id FROM source_metadata
WHERE source_host = ? AND source_path = ? AND source_query = ? WHERE source_host = ? AND source_path = ? AND source_query = ?
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&id) `, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&id)
if err != nil { if err != nil {
return 0, fmt.Errorf("failed to get source metadata ID: %w", err) return 0, fmt.Errorf("failed to get source metadata ID: %w", err)
} }
@@ -309,8 +296,12 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
} }
// Get actual item count and total size from content tables // Get actual item count and total size from content tables
_ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM request_cache`).Scan(&stats.TotalItems) _ = c.db.QueryRowContext(ctx,
_ = c.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes), 0) FROM output_content`).Scan(&stats.TotalSizeBytes) `SELECT COUNT(*) FROM request_cache`,
).Scan(&stats.TotalItems)
_ = c.db.QueryRowContext(ctx,
`SELECT COALESCE(SUM(size_bytes), 0) FROM output_content`,
).Scan(&stats.TotalSizeBytes)
// Compute hit rate as a ratio // Compute hit rate as a ratio
if stats.HitCount+stats.MissCount > 0 { if stats.HitCount+stats.MissCount > 0 {
@@ -324,11 +315,17 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) { func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) {
if hit { if hit {
_, _ = c.db.ExecContext(ctx, ` _, _ = c.db.ExecContext(ctx, `
UPDATE cache_stats SET hit_count = hit_count + 1, last_updated_at = CURRENT_TIMESTAMP WHERE id = 1 UPDATE cache_stats
SET hit_count = hit_count + 1,
last_updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`) `)
} else { } else {
_, _ = c.db.ExecContext(ctx, ` _, _ = c.db.ExecContext(ctx, `
UPDATE cache_stats SET miss_count = miss_count + 1, last_updated_at = CURRENT_TIMESTAMP WHERE id = 1 UPDATE cache_stats
SET miss_count = miss_count + 1,
last_updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`) `)
} }
@@ -342,3 +339,36 @@ func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64)
`, fetchBytes) `, fetchBytes)
} }
} }
// checkNegativeCache checks if a request is in the negative cache.
func (c *Cache) checkNegativeCache(
ctx context.Context, req *ImageRequest,
) (bool, error) {
var expiresAt time.Time
err := c.db.QueryRowContext(ctx, `
SELECT expires_at FROM negative_cache
WHERE source_host = ? AND source_path = ? AND source_query = ?
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("failed to check negative cache: %w", err)
}
// Check if expired
if time.Now().After(expiresAt) {
// Clean up expired entry
_, _ = c.db.ExecContext(ctx, `
DELETE FROM negative_cache
WHERE source_host = ? AND source_path = ? AND source_query = ?
`, req.SourceHost, req.SourcePath, req.SourceQuery)
return false, nil
}
return true, nil
}

View File

@@ -86,14 +86,15 @@ func setupTestDB(t *testing.T) *sql.DB {
INSERT INTO cache_stats (id) VALUES (1); INSERT INTO cache_stats (id) VALUES (1);
` `
if _, err := db.Exec(schema); err != nil { _, err = db.ExecContext(t.Context(), schema)
if err != nil {
t.Fatalf("failed to create schema: %v", err) t.Fatalf("failed to create schema: %v", err)
} }
return db return db
} }
func setupTestCache(t *testing.T) (*Cache, string) { func setupTestCache(t *testing.T) *Cache {
t.Helper() t.Helper()
tmpDir := t.TempDir() tmpDir := t.TempDir()
@@ -108,16 +109,18 @@ func setupTestCache(t *testing.T) (*Cache, string) {
t.Fatalf("failed to create cache: %v", err) t.Fatalf("failed to create cache: %v", err)
} }
return cache, tmpDir return cache
} }
func TestCache_LookupMiss(t *testing.T) { func TestCache_LookupMiss(t *testing.T) {
cache, _ := setupTestCache(t) t.Parallel()
cache := setupTestCache(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: testHostCDN,
SourcePath: "/photos/cat.jpg", SourcePath: testPathCat,
Size: Size{Width: 800, Height: 600}, Size: Size{Width: 800, Height: 600},
Format: FormatWebP, Format: FormatWebP,
Quality: 85, Quality: 85,
@@ -139,12 +142,14 @@ func TestCache_LookupMiss(t *testing.T) {
} }
func TestCache_StoreAndLookup(t *testing.T) { func TestCache_StoreAndLookup(t *testing.T) {
cache, _ := setupTestCache(t) t.Parallel()
cache := setupTestCache(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: testHostCDN,
SourcePath: "/photos/cat.jpg", SourcePath: testPathCat,
Size: Size{Width: 800, Height: 600}, Size: Size{Width: 800, Height: 600},
Format: FormatWebP, Format: FormatWebP,
Quality: 85, Quality: 85,
@@ -154,11 +159,12 @@ func TestCache_StoreAndLookup(t *testing.T) {
// Store source content // Store source content
sourceContent := []byte("fake jpeg data") sourceContent := []byte("fake jpeg data")
fetchResult := &httpfetcher.FetchResult{ fetchResult := &httpfetcher.FetchResult{
ContentType: "image/jpeg", ContentType: testContentTypeJPEG,
Headers: map[string][]string{"Content-Type": {"image/jpeg"}}, Headers: map[string][]string{"Content-Type": {testContentTypeJPEG}},
} }
contentHash, err := cache.StoreSource(ctx, req, bytes.NewReader(sourceContent), fetchResult) contentHash, err := cache.StoreSource(
ctx, req, bytes.NewReader(sourceContent), fetchResult)
if err != nil { if err != nil {
t.Fatalf("StoreSource() error = %v", err) t.Fatalf("StoreSource() error = %v", err)
} }
@@ -170,6 +176,7 @@ func TestCache_StoreAndLookup(t *testing.T) {
// Store variant // Store variant
cacheKey := CacheKey(req) cacheKey := CacheKey(req)
outputContent := []byte("fake webp data") outputContent := []byte("fake webp data")
err = cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp") err = cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
if err != nil { if err != nil {
t.Fatalf("StoreVariant() error = %v", err) t.Fatalf("StoreVariant() error = %v", err)
@@ -195,11 +202,13 @@ func TestCache_StoreAndLookup(t *testing.T) {
} }
func TestCache_NegativeCache(t *testing.T) { func TestCache_NegativeCache(t *testing.T) {
cache, _ := setupTestCache(t) t.Parallel()
cache := setupTestCache(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: testHostCDN,
SourcePath: "/photos/notfound.jpg", SourcePath: "/photos/notfound.jpg",
Size: Size{Width: 800, Height: 600}, Size: Size{Width: 800, Height: 600},
Format: FormatWebP, Format: FormatWebP,
@@ -223,6 +232,8 @@ func TestCache_NegativeCache(t *testing.T) {
} }
func TestCache_NegativeCacheExpiry(t *testing.T) { func TestCache_NegativeCacheExpiry(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir() tmpDir := t.TempDir()
db := setupTestDB(t) db := setupTestDB(t)
@@ -239,7 +250,7 @@ func TestCache_NegativeCacheExpiry(t *testing.T) {
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: testHostCDN,
SourcePath: "/photos/expired.jpg", SourcePath: "/photos/expired.jpg",
Size: Size{Width: 800, Height: 600}, Size: Size{Width: 800, Height: 600},
Format: FormatWebP, Format: FormatWebP,
@@ -266,11 +277,13 @@ func TestCache_NegativeCacheExpiry(t *testing.T) {
} }
func TestCache_VariantLookup(t *testing.T) { func TestCache_VariantLookup(t *testing.T) {
cache, _ := setupTestCache(t) t.Parallel()
cache := setupTestCache(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: testHostCDN,
SourcePath: "/photos/variant.jpg", SourcePath: "/photos/variant.jpg",
Size: Size{Width: 800, Height: 600}, Size: Size{Width: 800, Height: 600},
Format: FormatWebP, Format: FormatWebP,
@@ -281,6 +294,7 @@ func TestCache_VariantLookup(t *testing.T) {
// Store variant // Store variant
cacheKey := CacheKey(req) cacheKey := CacheKey(req)
outputContent := []byte("output data") outputContent := []byte("output data")
err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp") err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
if err != nil { if err != nil {
t.Fatalf("StoreVariant() error = %v", err) t.Fatalf("StoreVariant() error = %v", err)
@@ -312,11 +326,13 @@ func TestCache_VariantLookup(t *testing.T) {
} }
func TestCache_GetVariant_ReturnsContentType(t *testing.T) { func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
cache, _ := setupTestCache(t) t.Parallel()
cache := setupTestCache(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: testHostCDN,
SourcePath: "/photos/variantct.jpg", SourcePath: "/photos/variantct.jpg",
Size: Size{Width: 800, Height: 600}, Size: Size{Width: 800, Height: 600},
Format: FormatWebP, Format: FormatWebP,
@@ -327,6 +343,7 @@ func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
// Store variant // Store variant
cacheKey := CacheKey(req) cacheKey := CacheKey(req)
outputContent := []byte("output webp data") outputContent := []byte("output webp data")
err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp") err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
if err != nil { if err != nil {
t.Fatalf("StoreVariant() error = %v", err) t.Fatalf("StoreVariant() error = %v", err)
@@ -347,7 +364,8 @@ func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetVariant() error = %v", err) t.Fatalf("GetVariant() error = %v", err)
} }
defer reader.Close()
defer func() { _ = reader.Close() }()
if contentType != "image/webp" { if contentType != "image/webp" {
t.Errorf("GetVariant() ContentType = %q, want %q", contentType, "image/webp") t.Errorf("GetVariant() ContentType = %q, want %q", contentType, "image/webp")
@@ -359,11 +377,13 @@ func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
} }
func TestCache_GetVariant(t *testing.T) { func TestCache_GetVariant(t *testing.T) {
cache, _ := setupTestCache(t) t.Parallel()
cache := setupTestCache(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: testHostCDN,
SourcePath: "/photos/output.jpg", SourcePath: "/photos/output.jpg",
Size: Size{Width: 800, Height: 600}, Size: Size{Width: 800, Height: 600},
Format: FormatWebP, Format: FormatWebP,
@@ -374,6 +394,7 @@ func TestCache_GetVariant(t *testing.T) {
// Store variant // Store variant
cacheKey := CacheKey(req) cacheKey := CacheKey(req)
outputContent := []byte("the actual output content") outputContent := []byte("the actual output content")
err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp") err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
if err != nil { if err != nil {
t.Fatalf("StoreVariant() error = %v", err) t.Fatalf("StoreVariant() error = %v", err)
@@ -390,7 +411,8 @@ func TestCache_GetVariant(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetVariant() error = %v", err) t.Fatalf("GetVariant() error = %v", err)
} }
defer reader.Close()
defer func() { _ = reader.Close() }()
buf := make([]byte, 100) buf := make([]byte, 100)
n, _ := reader.Read(buf) n, _ := reader.Read(buf)
@@ -401,7 +423,9 @@ func TestCache_GetVariant(t *testing.T) {
} }
func TestCache_Stats(t *testing.T) { func TestCache_Stats(t *testing.T) {
cache, _ := setupTestCache(t) t.Parallel()
cache := setupTestCache(t)
ctx := context.Background() ctx := context.Background()
// Increment some stats // Increment some stats
@@ -424,6 +448,8 @@ func TestCache_Stats(t *testing.T) {
} }
func TestCache_CleanExpired(t *testing.T) { func TestCache_CleanExpired(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir() tmpDir := t.TempDir()
db := setupTestDB(t) db := setupTestDB(t)
@@ -436,7 +462,8 @@ func TestCache_CleanExpired(t *testing.T) {
// Insert expired negative cache entry directly // Insert expired negative cache entry directly
_, err := db.ExecContext(ctx, ` _, err := db.ExecContext(ctx, `
INSERT INTO negative_cache (source_host, source_path, source_query, status_code, expires_at) INSERT INTO negative_cache
(source_host, source_path, source_query, status_code, expires_at)
VALUES ('example.com', '/old.jpg', '', 404, datetime('now', '-1 hour')) VALUES ('example.com', '/old.jpg', '', 404, datetime('now', '-1 hour'))
`) `)
if err != nil { if err != nil {
@@ -445,7 +472,12 @@ func TestCache_CleanExpired(t *testing.T) {
// Verify it exists // Verify it exists
var count int var count int
db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count)
err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count)
if err != nil {
t.Fatalf("failed to count negative cache entries: %v", err)
}
if count != 1 { if count != 1 {
t.Fatalf("expected 1 negative cache entry, got %d", count) t.Fatalf("expected 1 negative cache entry, got %d", count)
} }
@@ -457,13 +489,19 @@ func TestCache_CleanExpired(t *testing.T) {
} }
// Verify it's gone // Verify it's gone
db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count) err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count)
if err != nil {
t.Fatalf("failed to count negative cache entries: %v", err)
}
if count != 0 { if count != 0 {
t.Errorf("expected 0 negative cache entries after clean, got %d", count) t.Errorf("expected 0 negative cache entries after clean, got %d", count)
} }
} }
func TestCache_StorageDirectoriesCreated(t *testing.T) { func TestCache_StorageDirectoriesCreated(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir() tmpDir := t.TempDir()
db := setupTestDB(t) db := setupTestDB(t)
@@ -483,7 +521,9 @@ func TestCache_StorageDirectoriesCreated(t *testing.T) {
for _, dir := range dirs { for _, dir := range dirs {
path := tmpDir + "/" + dir path := tmpDir + "/" + dir
if _, err := os.Stat(path); os.IsNotExist(err) {
_, err := os.Stat(path)
if os.IsNotExist(err) {
t.Errorf("directory %s was not created", dir) t.Errorf("directory %s was not created", dir)
} }
} }

View File

@@ -7,6 +7,8 @@ import (
) )
func TestSizePercentSafeWithZeroFetchBytes(t *testing.T) { func TestSizePercentSafeWithZeroFetchBytes(t *testing.T) {
t.Parallel()
// Simulate the calculation from processAndStore // Simulate the calculation from processAndStore
fetchBytes := int64(0) fetchBytes := int64(0)
outputSize := int64(100) outputSize := int64(100)
@@ -29,6 +31,8 @@ func TestSizePercentSafeWithZeroFetchBytes(t *testing.T) {
} }
func TestSizePercentNormalCase(t *testing.T) { func TestSizePercentNormalCase(t *testing.T) {
t.Parallel()
fetchBytes := int64(1000) fetchBytes := int64(1000)
outputSize := int64(500) outputSize := int64(500)

View File

@@ -90,6 +90,7 @@ func (r *ImageRequest) SourceURL() string {
if r.AllowHTTP { if r.AllowHTTP {
scheme = "http" scheme = "http"
} }
url := scheme + "://" + r.SourceHost + r.SourcePath url := scheme + "://" + r.SourceHost + r.SourcePath
if r.SourceQuery != "" { if r.SourceQuery != "" {
url += "?" + r.SourceQuery url += "?" + r.SourceQuery

View File

@@ -8,6 +8,8 @@ import (
) )
func TestNegativeCache_StoreAndCheck(t *testing.T) { func TestNegativeCache_StoreAndCheck(t *testing.T) {
t.Parallel()
db := setupTestDB(t) db := setupTestDB(t)
dir := t.TempDir() dir := t.TempDir()
@@ -22,7 +24,7 @@ func TestNegativeCache_StoreAndCheck(t *testing.T) {
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &ImageRequest{
SourceHost: "example.com", SourceHost: testHostExample,
SourcePath: "/missing.jpg", SourcePath: "/missing.jpg",
} }
@@ -31,6 +33,7 @@ func TestNegativeCache_StoreAndCheck(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if hit { if hit {
t.Error("expected no negative cache hit initially") t.Error("expected no negative cache hit initially")
} }
@@ -46,12 +49,15 @@ func TestNegativeCache_StoreAndCheck(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !hit { if !hit {
t.Error("expected negative cache hit after storing") t.Error("expected negative cache hit after storing")
} }
} }
func TestNegativeCache_Expired(t *testing.T) { func TestNegativeCache_Expired(t *testing.T) {
t.Parallel()
db := setupTestDB(t) db := setupTestDB(t)
dir := t.TempDir() dir := t.TempDir()
@@ -66,7 +72,7 @@ func TestNegativeCache_Expired(t *testing.T) {
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &ImageRequest{
SourceHost: "example.com", SourceHost: testHostExample,
SourcePath: "/expired.jpg", SourcePath: "/expired.jpg",
} }
@@ -84,12 +90,15 @@ func TestNegativeCache_Expired(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if hit { if hit {
t.Error("expected expired negative cache entry to be a miss") t.Error("expected expired negative cache entry to be a miss")
} }
} }
func TestService_Get_ReturnsErrorForNegativeCachedURL(t *testing.T) { func TestService_Get_ReturnsErrorForNegativeCachedURL(t *testing.T) {
t.Parallel()
// This test verifies that Service.Get() checks the negative cache // This test verifies that Service.Get() checks the negative cache
// We can't easily test the full pipeline without vips, but we can // We can't easily test the full pipeline without vips, but we can
// verify the error type // verify the error type

View File

@@ -18,7 +18,8 @@ import (
"sneak.berlin/go/pixa/internal/signature" "sneak.berlin/go/pixa/internal/signature"
) )
// Service implements the ImageCache interface, orchestrating cache, fetcher, and processor. // Service implements the ImageCache interface, orchestrating cache,
// fetcher, and processor.
type Service struct { type Service struct {
cache *Cache cache *Cache
fetcher httpfetcher.Fetcher fetcher httpfetcher.Fetcher
@@ -46,14 +47,21 @@ type ServiceConfig struct {
Logger *slog.Logger Logger *slog.Logger
} }
// Static errors for service construction and unimplemented operations.
var (
errCacheRequired = errors.New("cache is required")
errSigningKeyRequired = errors.New("signing key is required")
errPurgeNotImplemented = errors.New("purge not implemented")
)
// NewService creates a new image service. // NewService creates a new image service.
func NewService(cfg *ServiceConfig) (*Service, error) { func NewService(cfg *ServiceConfig) (*Service, error) {
if cfg.Cache == nil { if cfg.Cache == nil {
return nil, errors.New("cache is required") return nil, errCacheRequired
} }
if cfg.SigningKey == "" { if cfg.SigningKey == "" {
return nil, errors.New("signing key is required") return nil, errSigningKeyRequired
} }
// Resolve fetcher config for defaults // Resolve fetcher config for defaults
@@ -83,11 +91,14 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
} }
maxResponseSize := fetcherCfg.MaxResponseSize maxResponseSize := fetcherCfg.MaxResponseSize
processor := imageprocessor.New(
imageprocessor.Params{MaxInputBytes: maxResponseSize},
)
return &Service{ return &Service{
cache: cfg.Cache, cache: cfg.Cache,
fetcher: fetcher, fetcher: fetcher,
processor: imageprocessor.New(imageprocessor.Params{MaxInputBytes: maxResponseSize}), processor: processor,
signer: signer, signer: signer,
allowlist: allowlist.New(cfg.Allowlist), allowlist: allowlist.New(cfg.Allowlist),
log: log, log: log,
@@ -109,6 +120,7 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
if err != nil { if err != nil {
s.log.Warn("negative cache check failed", "error", err) s.log.Warn("negative cache check failed", "error", err)
} }
if negHit { if negHit {
s.log.Debug("negative cache hit", s.log.Debug("negative cache hit",
"host", req.SourceHost, "host", req.SourceHost,
@@ -145,6 +157,7 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
// Cache miss - check if we have source content cached // Cache miss - check if we have source content cached
cacheKey := CacheKey(req) cacheKey := CacheKey(req)
s.cache.IncrementStats(ctx, false, 0) s.cache.IncrementStats(ctx, false, 0)
response, err := s.processFromSourceOrFetch(ctx, req, cacheKey) response, err := s.processFromSourceOrFetch(ctx, req, cacheKey)
@@ -157,6 +170,57 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
return response, nil return response, nil
} }
// Warm pre-fetches and caches an image without returning it.
func (s *Service) Warm(ctx context.Context, req *ImageRequest) error {
_, err := s.Get(ctx, req)
return err
}
// Purge removes a cached image. Purging is not implemented yet.
func (s *Service) Purge(_ context.Context, _ *ImageRequest) error {
return errPurgeNotImplemented
}
// Stats returns cache statistics.
func (s *Service) Stats(ctx context.Context) (*CacheStats, error) {
return s.cache.Stats(ctx)
}
// ValidateRequest validates the request signature if required.
func (s *Service) ValidateRequest(req *ImageRequest) error {
// Check if host is allowed (no signature required)
sourceURL := req.SourceURL()
parsedURL, err := url.Parse(sourceURL)
if err != nil {
return fmt.Errorf("invalid source URL: %w", err)
}
if s.allowlist.IsAllowed(parsedURL) {
return nil
}
// Signature required for non-allowed hosts
return s.signer.Verify(signatureRequest(req))
}
// GenerateSignedURL generates a signed URL for the given request.
func (s *Service) GenerateSignedURL(
baseURL string,
req *ImageRequest,
ttl time.Duration,
) (string, error) {
sigReq := signatureRequest(req)
path, sig, exp := s.signer.GenerateSignedURL(sigReq, ttl)
// Propagate the generated signature and expiration back onto the request.
req.Expires = sigReq.Expires
req.Signature = sigReq.Signature
return fmt.Sprintf("%s%s?sig=%s&exp=%d", baseURL, path, sig, exp), nil
}
// loadCachedSource attempts to load source content from cache, returning nil // loadCachedSource attempts to load source content from cache, returning nil
// if the cached data is unavailable or exceeds maxResponseSize. // if the cached data is unavailable or exceeds maxResponseSize.
func (s *Service) loadCachedSource(contentHash ContentHash) []byte { func (s *Service) loadCachedSource(contentHash ContentHash) []byte {
@@ -191,7 +255,8 @@ func (s *Service) loadCachedSource(contentHash ContentHash) []byte {
return data return data
} }
// processFromSourceOrFetch processes an image, using cached source content if available. // processFromSourceOrFetch processes an image, using cached source content
// if available.
func (s *Service) processFromSourceOrFetch( func (s *Service) processFromSourceOrFetch(
ctx context.Context, ctx context.Context,
req *ImageRequest, req *ImageRequest,
@@ -203,8 +268,10 @@ func (s *Service) processFromSourceOrFetch(
s.log.Warn("source lookup failed", "error", err) s.log.Warn("source lookup failed", "error", err)
} }
var sourceData []byte var (
var fetchBytes int64 sourceData []byte
fetchBytes int64
)
if contentHash != "" { if contentHash != "" {
s.log.Debug("using cached source", "hash", contentHash) s.log.Debug("using cached source", "hash", contentHash)
@@ -258,6 +325,7 @@ func (s *Service) fetchAndProcess(
// Calculate download bitrate // Calculate download bitrate
fetchBytes := int64(len(sourceData)) fetchBytes := int64(len(sourceData))
var downloadRate string var downloadRate string
if fetchResult.FetchDurationMs > 0 { if fetchResult.FetchDurationMs > 0 {
@@ -280,7 +348,8 @@ func (s *Service) fetchAndProcess(
) )
// Validate magic bytes match content type // Validate magic bytes match content type
if err := magic.ValidateMagicBytes(sourceData, fetchResult.ContentType); err != nil { err = magic.ValidateMagicBytes(sourceData, fetchResult.ContentType)
if err != nil {
return nil, fmt.Errorf("content validation failed: %w", err) return nil, fmt.Errorf("content validation failed: %w", err)
} }
@@ -332,7 +401,8 @@ func (s *Service) processAndStore(
var sizePercent float64 var sizePercent float64
if fetchBytes > 0 { if fetchBytes > 0 {
sizePercent = float64(outputSize) / float64(fetchBytes) * 100.0 //nolint:mnd // percentage calculation //nolint:mnd // percentage calculation
sizePercent = float64(outputSize) / float64(fetchBytes) * 100.0
} }
s.log.Info("image converted", s.log.Info("image converted",
@@ -342,8 +412,10 @@ func (s *Service) processAndStore(
"dst_format", req.Format, "dst_format", req.Format,
"src_bytes", fetchBytes, "src_bytes", fetchBytes,
"dst_bytes", outputSize, "dst_bytes", outputSize,
"src_dimensions", fmt.Sprintf("%dx%d", processResult.InputWidth, processResult.InputHeight), "src_dimensions", fmt.Sprintf("%dx%d",
"dst_dimensions", fmt.Sprintf("%dx%d", processResult.Width, processResult.Height), processResult.InputWidth, processResult.InputHeight),
"dst_dimensions", fmt.Sprintf("%dx%d",
processResult.Width, processResult.Height),
"size_ratio", fmt.Sprintf("%.1f%%", sizePercent), "size_ratio", fmt.Sprintf("%.1f%%", sizePercent),
"convert_ms", processDuration.Milliseconds(), "convert_ms", processDuration.Milliseconds(),
"quality", req.Quality, "quality", req.Quality,
@@ -351,7 +423,10 @@ func (s *Service) processAndStore(
) )
// Store variant to cache // Store variant to cache
if err := s.cache.StoreVariant(cacheKey, bytes.NewReader(processedData), processResult.ContentType); err != nil { err = s.cache.StoreVariant(
cacheKey, bytes.NewReader(processedData), processResult.ContentType,
)
if err != nil {
s.log.Warn("failed to store variant", "error", err) s.log.Warn("failed to store variant", "error", err)
// Continue even if caching fails // Continue even if caching fails
} }
@@ -365,58 +440,6 @@ func (s *Service) processAndStore(
}, nil }, nil
} }
// Warm pre-fetches and caches an image without returning it.
func (s *Service) Warm(ctx context.Context, req *ImageRequest) error {
_, err := s.Get(ctx, req)
return err
}
// Purge removes a cached image.
func (s *Service) Purge(_ context.Context, _ *ImageRequest) error {
// TODO: Implement purge
return errors.New("purge not implemented")
}
// Stats returns cache statistics.
func (s *Service) Stats(ctx context.Context) (*CacheStats, error) {
return s.cache.Stats(ctx)
}
// ValidateRequest validates the request signature if required.
func (s *Service) ValidateRequest(req *ImageRequest) error {
// Check if host is allowed (no signature required)
sourceURL := req.SourceURL()
parsedURL, err := url.Parse(sourceURL)
if err != nil {
return fmt.Errorf("invalid source URL: %w", err)
}
if s.allowlist.IsAllowed(parsedURL) {
return nil
}
// Signature required for non-allowed hosts
return s.signer.Verify(signatureRequest(req))
}
// GenerateSignedURL generates a signed URL for the given request.
func (s *Service) GenerateSignedURL(
baseURL string,
req *ImageRequest,
ttl time.Duration,
) (string, error) {
sigReq := signatureRequest(req)
path, sig, exp := s.signer.GenerateSignedURL(sigReq, ttl)
// Propagate the generated signature and expiration back onto the request.
req.Expires = sigReq.Expires
req.Signature = sigReq.Signature
return fmt.Sprintf("%s%s?sig=%s&exp=%d", baseURL, path, sig, exp), nil
}
// signatureRequest projects an ImageRequest onto the standalone // signatureRequest projects an ImageRequest onto the standalone
// signature.Request type used by the signature package. This keeps the // signature.Request type used by the signature package. This keeps the
// import edge one-way: imgcache depends on signature, never the reverse. // import edge one-way: imgcache depends on signature, never the reverse.

View File

@@ -10,13 +10,22 @@ import (
"sneak.berlin/go/pixa/internal/signature" "sneak.berlin/go/pixa/internal/signature"
) )
// Test data literals used repeatedly in this file (goconst).
const (
testPathPhoto = "/images/photo.jpg"
testPathUpload = "/uploads/image.jpg"
testSigningKey = "test-signing-key-12345"
)
func TestService_Get_AllowlistedHost(t *testing.T) { func TestService_Get_AllowlistedHost(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &ImageRequest{
SourceHost: fixtures.GoodHost, SourceHost: fixtures.GoodHost,
SourcePath: "/images/photo.jpg", SourcePath: testPathPhoto,
Size: Size{Width: 50, Height: 50}, Size: Size{Width: 50, Height: 50},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -27,7 +36,8 @@ func TestService_Get_AllowlistedHost(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Get() error = %v", err) t.Fatalf("Get() error = %v", err)
} }
defer resp.Content.Close()
defer func() { _ = resp.Content.Close() }()
// Verify we got content // Verify we got content
data, err := io.ReadAll(resp.Content) data, err := io.ReadAll(resp.Content)
@@ -39,17 +49,19 @@ func TestService_Get_AllowlistedHost(t *testing.T) {
t.Error("expected non-empty response") t.Error("expected non-empty response")
} }
if resp.ContentType != "image/jpeg" { if resp.ContentType != testContentTypeJPEG {
t.Errorf("ContentType = %q, want %q", resp.ContentType, "image/jpeg") t.Errorf("ContentType = %q, want %q", resp.ContentType, testContentTypeJPEG)
} }
} }
func TestService_Get_NonAllowlistedHost_NoSignature(t *testing.T) { func TestService_Get_NonAllowlistedHost_NoSignature(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t, WithSigningKey("test-key")) svc, fixtures := SetupTestService(t, WithSigningKey("test-key"))
req := &ImageRequest{ req := &ImageRequest{
SourceHost: fixtures.OtherHost, SourceHost: fixtures.OtherHost,
SourcePath: "/uploads/image.jpg", SourcePath: testPathUpload,
Size: Size{Width: 50, Height: 50}, Size: Size{Width: 50, Height: 50},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -64,13 +76,15 @@ func TestService_Get_NonAllowlistedHost_NoSignature(t *testing.T) {
} }
func TestService_Get_NonAllowlistedHost_ValidSignature(t *testing.T) { func TestService_Get_NonAllowlistedHost_ValidSignature(t *testing.T) {
signingKey := "test-signing-key-12345" t.Parallel()
signingKey := testSigningKey
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey)) svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &ImageRequest{
SourceHost: fixtures.OtherHost, SourceHost: fixtures.OtherHost,
SourcePath: "/uploads/image.jpg", SourcePath: testPathUpload,
Size: Size{Width: 50, Height: 50}, Size: Size{Width: 50, Height: 50},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -93,7 +107,8 @@ func TestService_Get_NonAllowlistedHost_ValidSignature(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Get() error = %v", err) t.Fatalf("Get() error = %v", err)
} }
defer resp.Content.Close()
defer func() { _ = resp.Content.Close() }()
data, err := io.ReadAll(resp.Content) data, err := io.ReadAll(resp.Content)
if err != nil { if err != nil {
@@ -106,12 +121,14 @@ func TestService_Get_NonAllowlistedHost_ValidSignature(t *testing.T) {
} }
func TestService_Get_NonAllowlistedHost_ExpiredSignature(t *testing.T) { func TestService_Get_NonAllowlistedHost_ExpiredSignature(t *testing.T) {
signingKey := "test-signing-key-12345" t.Parallel()
signingKey := testSigningKey
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey)) svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
req := &ImageRequest{ req := &ImageRequest{
SourceHost: fixtures.OtherHost, SourceHost: fixtures.OtherHost,
SourcePath: "/uploads/image.jpg", SourcePath: testPathUpload,
Size: Size{Width: 50, Height: 50}, Size: Size{Width: 50, Height: 50},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -131,12 +148,14 @@ func TestService_Get_NonAllowlistedHost_ExpiredSignature(t *testing.T) {
} }
func TestService_Get_NonAllowlistedHost_InvalidSignature(t *testing.T) { func TestService_Get_NonAllowlistedHost_InvalidSignature(t *testing.T) {
signingKey := "test-signing-key-12345" t.Parallel()
signingKey := testSigningKey
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey)) svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
req := &ImageRequest{ req := &ImageRequest{
SourceHost: fixtures.OtherHost, SourceHost: fixtures.OtherHost,
SourcePath: "/uploads/image.jpg", SourcePath: testPathUpload,
Size: Size{Width: 50, Height: 50}, Size: Size{Width: 50, Height: 50},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -159,6 +178,8 @@ func TestService_Get_NonAllowlistedHost_InvalidSignature(t *testing.T) {
// signature for one host must not verify for a different host, even // signature for one host must not verify for a different host, even
// if they share a domain suffix. // if they share a domain suffix.
func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) { func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
t.Parallel()
signingKey := "test-signing-key-must-be-32-chars" signingKey := "test-signing-key-must-be-32-chars"
svc, _ := SetupTestService(t, svc, _ := SetupTestService(t,
WithSigningKey(signingKey), WithSigningKey(signingKey),
@@ -169,8 +190,8 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
// Sign a request for "cdn.example.com" // Sign a request for "cdn.example.com"
signedReq := &ImageRequest{ signedReq := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: testHostCDN,
SourcePath: "/photos/cat.jpg", SourcePath: testPathCat,
Size: Size{Width: 50, Height: 50}, Size: Size{Width: 50, Height: 50},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -181,6 +202,8 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
// The original request should pass validation // The original request should pass validation
t.Run("exact host passes", func(t *testing.T) { t.Run("exact host passes", func(t *testing.T) {
t.Parallel()
err := svc.ValidateRequest(signedReq) err := svc.ValidateRequest(signedReq)
if err != nil { if err != nil {
t.Errorf("ValidateRequest() exact host failed: %v", err) t.Errorf("ValidateRequest() exact host failed: %v", err)
@@ -192,7 +215,7 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
name string name string
host string host string
}{ }{
{"parent domain", "example.com"}, {"parent domain", testHostExample},
{"sibling subdomain", "images.example.com"}, {"sibling subdomain", "images.example.com"},
{"deeper subdomain", "a.cdn.example.com"}, {"deeper subdomain", "a.cdn.example.com"},
{"evil suffix domain", "cdn.example.com.evil.com"}, {"evil suffix domain", "cdn.example.com.evil.com"},
@@ -201,6 +224,8 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name+" rejected", func(t *testing.T) { t.Run(tt.name+" rejected", func(t *testing.T) {
t.Parallel()
req := &ImageRequest{ req := &ImageRequest{
SourceHost: tt.host, SourceHost: tt.host,
SourcePath: signedReq.SourcePath, SourcePath: signedReq.SourcePath,
@@ -215,7 +240,8 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
err := svc.ValidateRequest(req) err := svc.ValidateRequest(req)
if err == nil { if err == nil {
t.Errorf("ValidateRequest() should reject signature for host %q (signed for %q)", t.Errorf(
"ValidateRequest() should reject signature for host %q (signed for %q)",
tt.host, signedReq.SourceHost) tt.host, signedReq.SourceHost)
} }
}) })
@@ -223,6 +249,8 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
} }
func TestService_Get_InvalidFile(t *testing.T) { func TestService_Get_InvalidFile(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
ctx := context.Background() ctx := context.Background()
@@ -243,6 +271,8 @@ func TestService_Get_InvalidFile(t *testing.T) {
} }
func TestService_Get_NotFound(t *testing.T) { func TestService_Get_NotFound(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
ctx := context.Background() ctx := context.Background()
@@ -262,6 +292,8 @@ func TestService_Get_NotFound(t *testing.T) {
} }
func TestService_Get_FormatConversion(t *testing.T) { func TestService_Get_FormatConversion(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
ctx := context.Background() ctx := context.Background()
@@ -273,7 +305,7 @@ func TestService_Get_FormatConversion(t *testing.T) {
}{ }{
{ {
name: "JPEG to PNG", name: "JPEG to PNG",
sourcePath: "/images/photo.jpg", sourcePath: testPathPhoto,
outFormat: FormatPNG, outFormat: FormatPNG,
wantMIME: "image/png", wantMIME: "image/png",
}, },
@@ -281,7 +313,7 @@ func TestService_Get_FormatConversion(t *testing.T) {
name: "PNG to JPEG", name: "PNG to JPEG",
sourcePath: "/images/logo.png", sourcePath: "/images/logo.png",
outFormat: FormatJPEG, outFormat: FormatJPEG,
wantMIME: "image/jpeg", wantMIME: testContentTypeJPEG,
}, },
{ {
name: "GIF to PNG", name: "GIF to PNG",
@@ -293,6 +325,8 @@ func TestService_Get_FormatConversion(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := &ImageRequest{ req := &ImageRequest{
SourceHost: fixtures.GoodHost, SourceHost: fixtures.GoodHost,
SourcePath: tt.sourcePath, SourcePath: tt.sourcePath,
@@ -306,7 +340,8 @@ func TestService_Get_FormatConversion(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Get() error = %v", err) t.Fatalf("Get() error = %v", err)
} }
defer resp.Content.Close()
defer func() { _ = resp.Content.Close() }()
if resp.ContentType != tt.wantMIME { if resp.ContentType != tt.wantMIME {
t.Errorf("ContentType = %q, want %q", resp.ContentType, tt.wantMIME) t.Errorf("ContentType = %q, want %q", resp.ContentType, tt.wantMIME)
@@ -341,12 +376,14 @@ func TestService_Get_FormatConversion(t *testing.T) {
} }
func TestService_Get_Caching(t *testing.T) { func TestService_Get_Caching(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &ImageRequest{
SourceHost: fixtures.GoodHost, SourceHost: fixtures.GoodHost,
SourcePath: "/images/photo.jpg", SourcePath: testPathPhoto,
Size: Size{Width: 50, Height: 50}, Size: Size{Width: 50, Height: 50},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -367,7 +404,8 @@ func TestService_Get_Caching(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to read first response: %v", err) t.Fatalf("failed to read first response: %v", err)
} }
resp1.Content.Close()
_ = resp1.Content.Close()
// Second request - should be a cache hit // Second request - should be a cache hit
resp2, err := svc.Get(ctx, req) resp2, err := svc.Get(ctx, req)
@@ -383,7 +421,8 @@ func TestService_Get_Caching(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to read second response: %v", err) t.Fatalf("failed to read second response: %v", err)
} }
resp2.Content.Close()
_ = resp2.Content.Close()
// Content should be identical // Content should be identical
if len(data1) != len(data2) { if len(data1) != len(data2) {
@@ -392,6 +431,8 @@ func TestService_Get_Caching(t *testing.T) {
} }
func TestService_Get_DifferentSizes(t *testing.T) { func TestService_Get_DifferentSizes(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
ctx := context.Background() ctx := context.Background()
@@ -402,12 +443,12 @@ func TestService_Get_DifferentSizes(t *testing.T) {
{Width: 75, Height: 75}, {Width: 75, Height: 75},
} }
var responses [][]byte responses := make([][]byte, 0, len(sizes))
for _, size := range sizes { for _, size := range sizes {
req := &ImageRequest{ req := &ImageRequest{
SourceHost: fixtures.GoodHost, SourceHost: fixtures.GoodHost,
SourcePath: "/images/photo.jpg", SourcePath: testPathPhoto,
Size: size, Size: size,
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -423,27 +464,31 @@ func TestService_Get_DifferentSizes(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to read response: %v", err) t.Fatalf("failed to read response: %v", err)
} }
resp.Content.Close()
_ = resp.Content.Close()
responses = append(responses, data) responses = append(responses, data)
} }
// All responses should be different sizes (different cache entries) // All responses should be different sizes (different cache entries)
for i := 0; i < len(responses)-1; i++ { for i := range len(responses) - 1 {
if len(responses[i]) == len(responses[i+1]) { if len(responses[i]) == len(responses[i+1]) {
// Not necessarily an error, but worth noting // Not necessarily an error, but worth noting
t.Logf("responses %d and %d have same size: %d bytes", i, i+1, len(responses[i])) t.Logf("responses %d and %d have same size: %d bytes",
i, i+1, len(responses[i]))
} }
} }
} }
func TestService_ValidateRequest_NoSigningKey(t *testing.T) { func TestService_ValidateRequest_NoSigningKey(t *testing.T) {
t.Parallel()
// Service with no signing key - all non-allowlisted requests should fail // Service with no signing key - all non-allowlisted requests should fail
svc, fixtures := SetupTestService(t, WithNoAllowlist()) svc, fixtures := SetupTestService(t, WithNoAllowlist())
req := &ImageRequest{ req := &ImageRequest{
SourceHost: fixtures.OtherHost, SourceHost: fixtures.OtherHost,
SourcePath: "/uploads/image.jpg", SourcePath: testPathUpload,
Size: Size{Width: 50, Height: 50}, Size: Size{Width: 50, Height: 50},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -452,11 +497,15 @@ func TestService_ValidateRequest_NoSigningKey(t *testing.T) {
err := svc.ValidateRequest(req) err := svc.ValidateRequest(req)
if err == nil { if err == nil {
t.Error("ValidateRequest() expected error when no signing key and host not allowlisted") t.Error(
"ValidateRequest() expected error when no signing key and host not allowlisted",
)
} }
} }
func TestService_Get_ContextCancellation(t *testing.T) { func TestService_Get_ContextCancellation(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@@ -464,7 +513,7 @@ func TestService_Get_ContextCancellation(t *testing.T) {
req := &ImageRequest{ req := &ImageRequest{
SourceHost: fixtures.GoodHost, SourceHost: fixtures.GoodHost,
SourcePath: "/images/photo.jpg", SourcePath: testPathPhoto,
Size: Size{Width: 50, Height: 50}, Size: Size{Width: 50, Height: 50},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -478,12 +527,14 @@ func TestService_Get_ContextCancellation(t *testing.T) {
} }
func TestService_Get_ReturnsETag(t *testing.T) { func TestService_Get_ReturnsETag(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &ImageRequest{
SourceHost: fixtures.GoodHost, SourceHost: fixtures.GoodHost,
SourcePath: "/images/photo.jpg", SourcePath: testPathPhoto,
Size: Size{Width: 50, Height: 50}, Size: Size{Width: 50, Height: 50},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -494,7 +545,8 @@ func TestService_Get_ReturnsETag(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Get() error = %v", err) t.Fatalf("Get() error = %v", err)
} }
defer resp.Content.Close()
defer func() { _ = resp.Content.Close() }()
// ETag should be set // ETag should be set
if resp.ETag == "" { if resp.ETag == "" {
@@ -508,12 +560,14 @@ func TestService_Get_ReturnsETag(t *testing.T) {
} }
func TestService_Get_ETagConsistency(t *testing.T) { func TestService_Get_ETagConsistency(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
ctx := context.Background() ctx := context.Background()
req := &ImageRequest{ req := &ImageRequest{
SourceHost: fixtures.GoodHost, SourceHost: fixtures.GoodHost,
SourcePath: "/images/photo.jpg", SourcePath: testPathPhoto,
Size: Size{Width: 50, Height: 50}, Size: Size{Width: 50, Height: 50},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -525,16 +579,20 @@ func TestService_Get_ETagConsistency(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Get() first request error = %v", err) t.Fatalf("Get() first request error = %v", err)
} }
etag1 := resp1.ETag etag1 := resp1.ETag
resp1.Content.Close()
_ = resp1.Content.Close()
// Second request (from cache) // Second request (from cache)
resp2, err := svc.Get(ctx, req) resp2, err := svc.Get(ctx, req)
if err != nil { if err != nil {
t.Fatalf("Get() second request error = %v", err) t.Fatalf("Get() second request error = %v", err)
} }
etag2 := resp2.ETag etag2 := resp2.ETag
resp2.Content.Close()
_ = resp2.Content.Close()
// ETags should be identical for the same content // ETags should be identical for the same content
if etag1 != etag2 { if etag1 != etag2 {
@@ -543,13 +601,15 @@ func TestService_Get_ETagConsistency(t *testing.T) {
} }
func TestService_Get_DifferentETagsForDifferentContent(t *testing.T) { func TestService_Get_DifferentETagsForDifferentContent(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
ctx := context.Background() ctx := context.Background()
// Request same image at different sizes - should get different ETags // Request same image at different sizes - should get different ETags
req1 := &ImageRequest{ req1 := &ImageRequest{
SourceHost: fixtures.GoodHost, SourceHost: fixtures.GoodHost,
SourcePath: "/images/photo.jpg", SourcePath: testPathPhoto,
Size: Size{Width: 25, Height: 25}, Size: Size{Width: 25, Height: 25},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -558,7 +618,7 @@ func TestService_Get_DifferentETagsForDifferentContent(t *testing.T) {
req2 := &ImageRequest{ req2 := &ImageRequest{
SourceHost: fixtures.GoodHost, SourceHost: fixtures.GoodHost,
SourcePath: "/images/photo.jpg", SourcePath: testPathPhoto,
Size: Size{Width: 50, Height: 50}, Size: Size{Width: 50, Height: 50},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -569,15 +629,19 @@ func TestService_Get_DifferentETagsForDifferentContent(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Get() first request error = %v", err) t.Fatalf("Get() first request error = %v", err)
} }
etag1 := resp1.ETag etag1 := resp1.ETag
resp1.Content.Close()
_ = resp1.Content.Close()
resp2, err := svc.Get(ctx, req2) resp2, err := svc.Get(ctx, req2)
if err != nil { if err != nil {
t.Fatalf("Get() second request error = %v", err) t.Fatalf("Get() second request error = %v", err)
} }
etag2 := resp2.ETag etag2 := resp2.ETag
resp2.Content.Close()
_ = resp2.Content.Close()
// ETags should be different for different content // ETags should be different for different content
if etag1 == etag2 { if etag1 == etag2 {

View File

@@ -3,13 +3,16 @@ package imgcache
import "testing" import "testing"
func TestImageRequest_SourceURL_DefaultHTTPS(t *testing.T) { func TestImageRequest_SourceURL_DefaultHTTPS(t *testing.T) {
t.Parallel()
req := &ImageRequest{ req := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: testHostCDN,
SourcePath: "/photos/cat.jpg", SourcePath: testPathCat,
SourceQuery: "v=2", SourceQuery: "v=2",
} }
got := req.SourceURL() got := req.SourceURL()
want := "https://cdn.example.com/photos/cat.jpg?v=2" want := "https://cdn.example.com/photos/cat.jpg?v=2"
if got != want { if got != want {
t.Errorf("SourceURL() = %q, want %q", got, want) t.Errorf("SourceURL() = %q, want %q", got, want)
@@ -17,13 +20,16 @@ func TestImageRequest_SourceURL_DefaultHTTPS(t *testing.T) {
} }
func TestImageRequest_SourceURL_AllowHTTP(t *testing.T) { func TestImageRequest_SourceURL_AllowHTTP(t *testing.T) {
t.Parallel()
req := &ImageRequest{ req := &ImageRequest{
SourceHost: "localhost:8080", SourceHost: "localhost:8080",
SourcePath: "/photos/cat.jpg", SourcePath: testPathCat,
AllowHTTP: true, AllowHTTP: true,
} }
got := req.SourceURL() got := req.SourceURL()
want := "http://localhost:8080/photos/cat.jpg" want := "http://localhost:8080/photos/cat.jpg"
if got != want { if got != want {
t.Errorf("SourceURL() = %q, want %q", got, want) t.Errorf("SourceURL() = %q, want %q", got, want)
@@ -31,8 +37,10 @@ func TestImageRequest_SourceURL_AllowHTTP(t *testing.T) {
} }
func TestImageRequest_SourceURL_AllowHTTPFalse(t *testing.T) { func TestImageRequest_SourceURL_AllowHTTPFalse(t *testing.T) {
t.Parallel()
req := &ImageRequest{ req := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: testHostCDN,
SourcePath: "/img.jpg", SourcePath: "/img.jpg",
AllowHTTP: false, AllowHTTP: false,
} }

View File

@@ -12,18 +12,25 @@ import (
func setupStatsTestDB(t *testing.T) *sql.DB { func setupStatsTestDB(t *testing.T) *sql.DB {
t.Helper() t.Helper()
db, err := sql.Open("sqlite", ":memory:") db, err := sql.Open("sqlite", ":memory:")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := database.ApplyMigrations(context.Background(), db, nil); err != nil {
err = database.ApplyMigrations(context.Background(), db, nil)
if err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(func() { db.Close() })
t.Cleanup(func() { _ = db.Close() })
return db return db
} }
func TestStats_HitRateIsRatio(t *testing.T) { func TestStats_HitRateIsRatio(t *testing.T) {
t.Parallel()
db := setupStatsTestDB(t) db := setupStatsTestDB(t)
dir := t.TempDir() dir := t.TempDir()
@@ -40,7 +47,9 @@ func TestStats_HitRateIsRatio(t *testing.T) {
// Set some hit/miss counts and a transform_count // Set some hit/miss counts and a transform_count
_, err = db.ExecContext(ctx, ` _, err = db.ExecContext(ctx, `
UPDATE cache_stats SET hit_count = 75, miss_count = 25, transform_count = 9999 WHERE id = 1 UPDATE cache_stats
SET hit_count = 75, miss_count = 25, transform_count = 9999
WHERE id = 1
`) `)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -54,6 +63,7 @@ func TestStats_HitRateIsRatio(t *testing.T) {
if stats.HitCount != 75 { if stats.HitCount != 75 {
t.Errorf("HitCount = %d, want 75", stats.HitCount) t.Errorf("HitCount = %d, want 75", stats.HitCount)
} }
if stats.MissCount != 25 { if stats.MissCount != 25 {
t.Errorf("MissCount = %d, want 25", stats.MissCount) t.Errorf("MissCount = %d, want 25", stats.MissCount)
} }
@@ -61,11 +71,14 @@ func TestStats_HitRateIsRatio(t *testing.T) {
// HitRate should be 0.75, NOT 9999 (transform_count) // HitRate should be 0.75, NOT 9999 (transform_count)
expectedRate := 0.75 expectedRate := 0.75
if math.Abs(stats.HitRate-expectedRate) > 0.001 { if math.Abs(stats.HitRate-expectedRate) > 0.001 {
t.Errorf("HitRate = %f, want %f (was it scanning transform_count?)", stats.HitRate, expectedRate) t.Errorf("HitRate = %f, want %f (was it scanning transform_count?)",
stats.HitRate, expectedRate)
} }
} }
func TestStats_ZeroCounts(t *testing.T) { func TestStats_ZeroCounts(t *testing.T) {
t.Parallel()
db := setupStatsTestDB(t) db := setupStatsTestDB(t)
dir := t.TempDir() dir := t.TempDir()

View File

@@ -44,7 +44,8 @@ type ContentStorage struct {
// NewContentStorage creates a new content storage at the given base directory. // NewContentStorage creates a new content storage at the given base directory.
func NewContentStorage(baseDir string) (*ContentStorage, error) { func NewContentStorage(baseDir string) (*ContentStorage, error) {
if err := os.MkdirAll(baseDir, StorageDirPerm); err != nil { err := os.MkdirAll(baseDir, StorageDirPerm)
if err != nil {
return nil, fmt.Errorf("failed to create storage directory: %w", err) return nil, fmt.Errorf("failed to create storage directory: %w", err)
} }
@@ -53,7 +54,7 @@ func NewContentStorage(baseDir string) (*ContentStorage, error) {
// Store writes content to storage and returns its SHA256 hash. // Store writes content to storage and returns its SHA256 hash.
// The content is read fully into memory to compute the hash before writing. // The content is read fully into memory to compute the hash before writing.
func (s *ContentStorage) Store(r io.Reader) (hash ContentHash, size int64, err error) { func (s *ContentStorage) Store(r io.Reader) (ContentHash, int64, error) {
// Read all content to compute hash // Read all content to compute hash
data, err := io.ReadAll(r) data, err := io.ReadAll(r)
if err != nil { if err != nil {
@@ -62,20 +63,23 @@ func (s *ContentStorage) Store(r io.Reader) (hash ContentHash, size int64, err e
// Compute hash // Compute hash
h := sha256.Sum256(data) h := sha256.Sum256(data)
hash = ContentHash(hex.EncodeToString(h[:])) hash := ContentHash(hex.EncodeToString(h[:]))
size = int64(len(data)) size := int64(len(data))
// Build path: <basedir>/<ab>/<cd>/<hash> // Build path: <basedir>/<ab>/<cd>/<hash>
path := s.hashToPath(hash) path := s.hashToPath(hash)
// Check if already exists // Check if already exists
if _, err := os.Stat(path); err == nil { _, err = os.Stat(path)
if err == nil {
return hash, size, nil return hash, size, nil
} }
// Create directory structure // Create directory structure
dir := filepath.Dir(path) dir := filepath.Dir(path)
if err := os.MkdirAll(dir, StorageDirPerm); err != nil {
err = os.MkdirAll(dir, StorageDirPerm)
if err != nil {
return "", 0, fmt.Errorf("failed to create directory: %w", err) return "", 0, fmt.Errorf("failed to create directory: %w", err)
} }
@@ -84,27 +88,29 @@ func (s *ContentStorage) Store(r io.Reader) (hash ContentHash, size int64, err e
if err != nil { if err != nil {
return "", 0, fmt.Errorf("failed to create temp file: %w", err) return "", 0, fmt.Errorf("failed to create temp file: %w", err)
} }
tmpPath := tmpFile.Name() tmpPath := tmpFile.Name()
defer func() { _, err = tmpFile.Write(data)
if err != nil { if err != nil {
_ = os.Remove(tmpPath)
}
}()
if _, err := tmpFile.Write(data); err != nil {
_ = tmpFile.Close() _ = tmpFile.Close()
_ = os.Remove(tmpPath)
return "", 0, fmt.Errorf("failed to write content: %w", err) return "", 0, fmt.Errorf("failed to write content: %w", err)
} }
if err := tmpFile.Close(); err != nil { err = tmpFile.Close()
if err != nil {
_ = os.Remove(tmpPath)
return "", 0, fmt.Errorf("failed to close temp file: %w", err) return "", 0, fmt.Errorf("failed to close temp file: %w", err)
} }
// Atomic rename // Atomic rename
//nolint:gosec // G703: paths from internal SHA256 hashes err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
if err := os.Rename(filepath.Clean(tmpPath), filepath.Clean(path)); err != nil { if err != nil {
_ = os.Remove(tmpPath)
return "", 0, fmt.Errorf("failed to rename temp file: %w", err) return "", 0, fmt.Errorf("failed to rename temp file: %w", err)
} }
@@ -188,7 +194,8 @@ type MetadataStorage struct {
// NewMetadataStorage creates a new metadata storage at the given base directory. // NewMetadataStorage creates a new metadata storage at the given base directory.
func NewMetadataStorage(baseDir string) (*MetadataStorage, error) { func NewMetadataStorage(baseDir string) (*MetadataStorage, error) {
if err := os.MkdirAll(baseDir, StorageDirPerm); err != nil { err := os.MkdirAll(baseDir, StorageDirPerm)
if err != nil {
return nil, fmt.Errorf("failed to create metadata directory: %w", err) return nil, fmt.Errorf("failed to create metadata directory: %w", err)
} }
@@ -196,6 +203,8 @@ func NewMetadataStorage(baseDir string) (*MetadataStorage, error) {
} }
// SourceMetadata represents cached metadata about a source URL. // SourceMetadata represents cached metadata about a source URL.
//
//nolint:tagliatelle // stored metadata format uses snake_case
type SourceMetadata struct { type SourceMetadata struct {
Host string `json:"host"` Host string `json:"host"`
Path string `json:"path"` Path string `json:"path"`
@@ -214,12 +223,16 @@ type SourceMetadata struct {
} }
// Store writes metadata to storage. // Store writes metadata to storage.
func (s *MetadataStorage) Store(host string, pathHash PathHash, meta *SourceMetadata) error { func (s *MetadataStorage) Store(
host string, pathHash PathHash, meta *SourceMetadata,
) error {
path := s.metaPath(host, pathHash) path := s.metaPath(host, pathHash)
// Create directory structure // Create directory structure
dir := filepath.Dir(path) dir := filepath.Dir(path)
if err := os.MkdirAll(dir, StorageDirPerm); err != nil {
err := os.MkdirAll(dir, StorageDirPerm)
if err != nil {
return fmt.Errorf("failed to create directory: %w", err) return fmt.Errorf("failed to create directory: %w", err)
} }
@@ -234,27 +247,29 @@ func (s *MetadataStorage) Store(host string, pathHash PathHash, meta *SourceMeta
if err != nil { if err != nil {
return fmt.Errorf("failed to create temp file: %w", err) return fmt.Errorf("failed to create temp file: %w", err)
} }
tmpPath := tmpFile.Name() tmpPath := tmpFile.Name()
defer func() { _, err = tmpFile.Write(data)
if err != nil { if err != nil {
_ = os.Remove(tmpPath)
}
}()
if _, err := tmpFile.Write(data); err != nil {
_ = tmpFile.Close() _ = tmpFile.Close()
_ = os.Remove(tmpPath)
return fmt.Errorf("failed to write metadata: %w", err) return fmt.Errorf("failed to write metadata: %w", err)
} }
if err := tmpFile.Close(); err != nil { err = tmpFile.Close()
if err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("failed to close temp file: %w", err) return fmt.Errorf("failed to close temp file: %w", err)
} }
// Atomic rename // Atomic rename
//nolint:gosec // G703: paths from internal SHA256 hashes err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
if err := os.Rename(filepath.Clean(tmpPath), filepath.Clean(path)); err != nil { if err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("failed to rename temp file: %w", err) return fmt.Errorf("failed to rename temp file: %w", err)
} }
@@ -262,7 +277,9 @@ func (s *MetadataStorage) Store(host string, pathHash PathHash, meta *SourceMeta
} }
// Load reads metadata from storage. // Load reads metadata from storage.
func (s *MetadataStorage) Load(host string, pathHash PathHash) (*SourceMetadata, error) { func (s *MetadataStorage) Load(
host string, pathHash PathHash,
) (*SourceMetadata, error) {
path := s.metaPath(host, pathHash) path := s.metaPath(host, pathHash)
data, err := os.ReadFile(path) //nolint:gosec // path derived from host+hash data, err := os.ReadFile(path) //nolint:gosec // path derived from host+hash
@@ -275,7 +292,9 @@ func (s *MetadataStorage) Load(host string, pathHash PathHash) (*SourceMetadata,
} }
var meta SourceMetadata var meta SourceMetadata
if err := json.Unmarshal(data, &meta); err != nil {
err = json.Unmarshal(data, &meta)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal metadata: %w", err) return nil, fmt.Errorf("failed to unmarshal metadata: %w", err)
} }
@@ -341,6 +360,8 @@ type VariantStorage struct {
} }
// VariantMeta contains metadata about a cached variant. // VariantMeta contains metadata about a cached variant.
//
//nolint:tagliatelle // stored metadata format uses snake_case
type VariantMeta struct { type VariantMeta struct {
ContentType string `json:"content_type"` ContentType string `json:"content_type"`
Size int64 `json:"size"` Size int64 `json:"size"`
@@ -349,7 +370,8 @@ type VariantMeta struct {
// NewVariantStorage creates a new variant storage at the given base directory. // NewVariantStorage creates a new variant storage at the given base directory.
func NewVariantStorage(baseDir string) (*VariantStorage, error) { func NewVariantStorage(baseDir string) (*VariantStorage, error) {
if err := os.MkdirAll(baseDir, StorageDirPerm); err != nil { err := os.MkdirAll(baseDir, StorageDirPerm)
if err != nil {
return nil, fmt.Errorf("failed to create variant storage directory: %w", err) return nil, fmt.Errorf("failed to create variant storage directory: %w", err)
} }
@@ -357,19 +379,23 @@ func NewVariantStorage(baseDir string) (*VariantStorage, error) {
} }
// Store writes content and metadata to storage at the given key. // Store writes content and metadata to storage at the given key.
func (s *VariantStorage) Store(key VariantKey, r io.Reader, contentType string) (size int64, err error) { func (s *VariantStorage) Store(
key VariantKey, r io.Reader, contentType string,
) (int64, error) {
data, err := io.ReadAll(r) data, err := io.ReadAll(r)
if err != nil { if err != nil {
return 0, fmt.Errorf("failed to read content: %w", err) return 0, fmt.Errorf("failed to read content: %w", err)
} }
size = int64(len(data)) size := int64(len(data))
path := s.keyToPath(key) path := s.keyToPath(key)
metaPath := path + ".meta" metaPath := path + ".meta"
// Create directory structure // Create directory structure
dir := filepath.Dir(path) dir := filepath.Dir(path)
if err := os.MkdirAll(dir, StorageDirPerm); err != nil {
err = os.MkdirAll(dir, StorageDirPerm)
if err != nil {
return 0, fmt.Errorf("failed to create directory: %w", err) return 0, fmt.Errorf("failed to create directory: %w", err)
} }
@@ -378,27 +404,29 @@ func (s *VariantStorage) Store(key VariantKey, r io.Reader, contentType string)
if err != nil { if err != nil {
return 0, fmt.Errorf("failed to create temp file: %w", err) return 0, fmt.Errorf("failed to create temp file: %w", err)
} }
tmpPath := tmpFile.Name() tmpPath := tmpFile.Name()
defer func() { _, err = tmpFile.Write(data)
if err != nil { if err != nil {
_ = os.Remove(tmpPath)
}
}()
if _, err := tmpFile.Write(data); err != nil {
_ = tmpFile.Close() _ = tmpFile.Close()
_ = os.Remove(tmpPath)
return 0, fmt.Errorf("failed to write content: %w", err) return 0, fmt.Errorf("failed to write content: %w", err)
} }
if err := tmpFile.Close(); err != nil { err = tmpFile.Close()
if err != nil {
_ = os.Remove(tmpPath)
return 0, fmt.Errorf("failed to close temp file: %w", err) return 0, fmt.Errorf("failed to close temp file: %w", err)
} }
// Atomic rename content // Atomic rename content
//nolint:gosec // G703: paths from internal SHA256 hashes err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
if err := os.Rename(filepath.Clean(tmpPath), filepath.Clean(path)); err != nil { if err != nil {
_ = os.Remove(tmpPath)
return 0, fmt.Errorf("failed to rename temp file: %w", err) return 0, fmt.Errorf("failed to rename temp file: %w", err)
} }
@@ -414,10 +442,8 @@ func (s *VariantStorage) Store(key VariantKey, r io.Reader, contentType string)
return 0, fmt.Errorf("failed to marshal metadata: %w", err) return 0, fmt.Errorf("failed to marshal metadata: %w", err)
} }
if err := os.WriteFile(metaPath, metaData, StorageFilePerm); err != nil { // Metadata write failure is non-fatal; content is already stored.
// Non-fatal, content is stored _ = os.WriteFile(metaPath, metaData, StorageFilePerm)
_ = err
}
return size, nil return size, nil
} }
@@ -438,8 +464,11 @@ func (s *VariantStorage) Load(key VariantKey) (io.ReadCloser, error) {
return f, nil return f, nil
} }
// LoadWithMeta returns a reader, size, and content type for the content at the given key. // LoadWithMeta returns a reader, size, and content type for the content at
func (s *VariantStorage) LoadWithMeta(key VariantKey) (io.ReadCloser, int64, string, error) { // the given key.
func (s *VariantStorage) LoadWithMeta(
key VariantKey,
) (io.ReadCloser, int64, string, error) {
path := s.keyToPath(key) path := s.keyToPath(key)
metaPath := path + ".meta" metaPath := path + ".meta"

View File

@@ -2,6 +2,7 @@ package imgcache
import ( import (
"bytes" "bytes"
"errors"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
@@ -9,13 +10,17 @@ import (
) )
func TestContentStorage_StoreAndLoad(t *testing.T) { func TestContentStorage_StoreAndLoad(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir() tmpDir := t.TempDir()
storage, err := NewContentStorage(tmpDir) storage, err := NewContentStorage(tmpDir)
if err != nil { if err != nil {
t.Fatalf("NewContentStorage() error = %v", err) t.Fatalf("NewContentStorage() error = %v", err)
} }
content := []byte("hello world") content := []byte("hello world")
hash, size, err := storage.Store(bytes.NewReader(content)) hash, size, err := storage.Store(bytes.NewReader(content))
if err != nil { if err != nil {
t.Fatalf("Store() error = %v", err) t.Fatalf("Store() error = %v", err)
@@ -31,8 +36,11 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
// Verify file exists at expected path // Verify file exists at expected path
hashStr := string(hash) hashStr := string(hash)
expectedPath := filepath.Join(tmpDir, hashStr[0:2], hashStr[2:4], hashStr) expectedPath := filepath.Join(tmpDir, hashStr[0:2], hashStr[2:4], hashStr)
if _, err := os.Stat(expectedPath); err != nil {
_, err = os.Stat(expectedPath)
if err != nil {
t.Errorf("File not at expected path %s: %v", expectedPath, err) t.Errorf("File not at expected path %s: %v", expectedPath, err)
} }
@@ -41,7 +49,8 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Load() error = %v", err) t.Fatalf("Load() error = %v", err)
} }
defer r.Close()
defer func() { _ = r.Close() }()
loaded, err := io.ReadAll(r) loaded, err := io.ReadAll(r)
if err != nil { if err != nil {
@@ -54,7 +63,10 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
} }
func TestContentStorage_StoreIdempotent(t *testing.T) { func TestContentStorage_StoreIdempotent(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir() tmpDir := t.TempDir()
storage, err := NewContentStorage(tmpDir) storage, err := NewContentStorage(tmpDir)
if err != nil { if err != nil {
t.Fatalf("NewContentStorage() error = %v", err) t.Fatalf("NewContentStorage() error = %v", err)
@@ -78,26 +90,33 @@ func TestContentStorage_StoreIdempotent(t *testing.T) {
} }
func TestContentStorage_LoadNotFound(t *testing.T) { func TestContentStorage_LoadNotFound(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir() tmpDir := t.TempDir()
storage, err := NewContentStorage(tmpDir) storage, err := NewContentStorage(tmpDir)
if err != nil { if err != nil {
t.Fatalf("NewContentStorage() error = %v", err) t.Fatalf("NewContentStorage() error = %v", err)
} }
_, err = storage.Load(ContentHash("nonexistent")) _, err = storage.Load(ContentHash("nonexistent"))
if err != ErrNotFound { if !errors.Is(err, ErrNotFound) {
t.Errorf("Load() error = %v, want ErrNotFound", err) t.Errorf("Load() error = %v, want ErrNotFound", err)
} }
} }
func TestContentStorage_Delete(t *testing.T) { func TestContentStorage_Delete(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir() tmpDir := t.TempDir()
storage, err := NewContentStorage(tmpDir) storage, err := NewContentStorage(tmpDir)
if err != nil { if err != nil {
t.Fatalf("NewContentStorage() error = %v", err) t.Fatalf("NewContentStorage() error = %v", err)
} }
content := []byte("to be deleted") content := []byte("to be deleted")
hash, _, err := storage.Store(bytes.NewReader(content)) hash, _, err := storage.Store(bytes.NewReader(content))
if err != nil { if err != nil {
t.Fatalf("Store() error = %v", err) t.Fatalf("Store() error = %v", err)
@@ -107,7 +126,8 @@ func TestContentStorage_Delete(t *testing.T) {
t.Error("Exists() = false, want true") t.Error("Exists() = false, want true")
} }
if err := storage.Delete(hash); err != nil { err = storage.Delete(hash)
if err != nil {
t.Fatalf("Delete() error = %v", err) t.Fatalf("Delete() error = %v", err)
} }
@@ -117,20 +137,27 @@ func TestContentStorage_Delete(t *testing.T) {
} }
func TestContentStorage_DeleteNonexistent(t *testing.T) { func TestContentStorage_DeleteNonexistent(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir() tmpDir := t.TempDir()
storage, err := NewContentStorage(tmpDir) storage, err := NewContentStorage(tmpDir)
if err != nil { if err != nil {
t.Fatalf("NewContentStorage() error = %v", err) t.Fatalf("NewContentStorage() error = %v", err)
} }
// Should not error // Should not error
if err := storage.Delete(ContentHash("nonexistent")); err != nil { err = storage.Delete(ContentHash("nonexistent"))
if err != nil {
t.Errorf("Delete() error = %v, want nil", err) t.Errorf("Delete() error = %v, want nil", err)
} }
} }
func TestContentStorage_HashToPath(t *testing.T) { func TestContentStorage_HashToPath(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir() tmpDir := t.TempDir()
storage, err := NewContentStorage(tmpDir) storage, err := NewContentStorage(tmpDir)
if err != nil { if err != nil {
t.Fatalf("NewContentStorage() error = %v", err) t.Fatalf("NewContentStorage() error = %v", err)
@@ -138,50 +165,59 @@ func TestContentStorage_HashToPath(t *testing.T) {
// Test by storing and verifying the resulting path structure // Test by storing and verifying the resulting path structure
content := []byte("test content for path verification") content := []byte("test content for path verification")
hash, _, err := storage.Store(bytes.NewReader(content)) hash, _, err := storage.Store(bytes.NewReader(content))
if err != nil { if err != nil {
t.Fatalf("Store() error = %v", err) t.Fatalf("Store() error = %v", err)
} }
hashStr := string(hash) hashStr := string(hash)
expectedPath := filepath.Join(tmpDir, hashStr[0:2], hashStr[2:4], hashStr) expectedPath := filepath.Join(tmpDir, hashStr[0:2], hashStr[2:4], hashStr)
if _, err := os.Stat(expectedPath); err != nil {
_, err = os.Stat(expectedPath)
if err != nil {
t.Errorf("File not at expected path %s: %v", expectedPath, err) t.Errorf("File not at expected path %s: %v", expectedPath, err)
} }
} }
func TestMetadataStorage_StoreAndLoad(t *testing.T) { func TestMetadataStorage_StoreAndLoad(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir() tmpDir := t.TempDir()
storage, err := NewMetadataStorage(tmpDir) storage, err := NewMetadataStorage(tmpDir)
if err != nil { if err != nil {
t.Fatalf("NewMetadataStorage() error = %v", err) t.Fatalf("NewMetadataStorage() error = %v", err)
} }
meta := &SourceMetadata{ meta := &SourceMetadata{
Host: "cdn.example.com", Host: testHostCDN,
Path: "/photos/cat.jpg", Path: testPathCat,
ContentHash: "abc123", ContentHash: "abc123",
StatusCode: 200, StatusCode: 200,
ContentType: "image/jpeg", ContentType: testContentTypeJPEG,
FetchedAt: 1704067200, FetchedAt: 1704067200,
ETag: `"etag123"`, ETag: `"etag123"`,
} }
pathHash := HashPath("/photos/cat.jpg") pathHash := HashPath(testPathCat)
err = storage.Store("cdn.example.com", pathHash, meta) err = storage.Store(testHostCDN, pathHash, meta)
if err != nil { if err != nil {
t.Fatalf("Store() error = %v", err) t.Fatalf("Store() error = %v", err)
} }
// Verify file exists at expected path // Verify file exists at expected path
expectedPath := filepath.Join(tmpDir, "cdn.example.com", string(pathHash)+".json") expectedPath := filepath.Join(tmpDir, testHostCDN, string(pathHash)+".json")
if _, err := os.Stat(expectedPath); err != nil {
_, err = os.Stat(expectedPath)
if err != nil {
t.Errorf("File not at expected path %s: %v", expectedPath, err) t.Errorf("File not at expected path %s: %v", expectedPath, err)
} }
// Load and verify // Load and verify
loaded, err := storage.Load("cdn.example.com", pathHash) loaded, err := storage.Load(testHostCDN, pathHash)
if err != nil { if err != nil {
t.Fatalf("Load() error = %v", err) t.Fatalf("Load() error = %v", err)
} }
@@ -208,55 +244,64 @@ func TestMetadataStorage_StoreAndLoad(t *testing.T) {
} }
func TestMetadataStorage_LoadNotFound(t *testing.T) { func TestMetadataStorage_LoadNotFound(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir() tmpDir := t.TempDir()
storage, err := NewMetadataStorage(tmpDir) storage, err := NewMetadataStorage(tmpDir)
if err != nil { if err != nil {
t.Fatalf("NewMetadataStorage() error = %v", err) t.Fatalf("NewMetadataStorage() error = %v", err)
} }
_, err = storage.Load("example.com", PathHash("nonexistent")) _, err = storage.Load(testHostExample, PathHash("nonexistent"))
if err != ErrNotFound { if !errors.Is(err, ErrNotFound) {
t.Errorf("Load() error = %v, want ErrNotFound", err) t.Errorf("Load() error = %v, want ErrNotFound", err)
} }
} }
func TestMetadataStorage_Delete(t *testing.T) { func TestMetadataStorage_Delete(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir() tmpDir := t.TempDir()
storage, err := NewMetadataStorage(tmpDir) storage, err := NewMetadataStorage(tmpDir)
if err != nil { if err != nil {
t.Fatalf("NewMetadataStorage() error = %v", err) t.Fatalf("NewMetadataStorage() error = %v", err)
} }
meta := &SourceMetadata{ meta := &SourceMetadata{
Host: "example.com", Host: testHostExample,
Path: "/test.jpg", Path: "/test.jpg",
StatusCode: 200, StatusCode: 200,
} }
pathHash := HashPath("/test.jpg") pathHash := HashPath("/test.jpg")
err = storage.Store("example.com", pathHash, meta) err = storage.Store(testHostExample, pathHash, meta)
if err != nil { if err != nil {
t.Fatalf("Store() error = %v", err) t.Fatalf("Store() error = %v", err)
} }
if !storage.Exists("example.com", pathHash) { if !storage.Exists(testHostExample, pathHash) {
t.Error("Exists() = false, want true") t.Error("Exists() = false, want true")
} }
if err := storage.Delete("example.com", pathHash); err != nil { err = storage.Delete(testHostExample, pathHash)
if err != nil {
t.Fatalf("Delete() error = %v", err) t.Fatalf("Delete() error = %v", err)
} }
if storage.Exists("example.com", pathHash) { if storage.Exists(testHostExample, pathHash) {
t.Error("Exists() = true after delete, want false") t.Error("Exists() = true after delete, want false")
} }
} }
func TestHashPath(t *testing.T) { func TestHashPath(t *testing.T) {
t.Parallel()
// Same input should produce same hash // Same input should produce same hash
hash1 := HashPath("/photos/cat.jpg") hash1 := HashPath(testPathCat)
hash2 := HashPath("/photos/cat.jpg") hash2 := HashPath(testPathCat)
if hash1 != hash2 { if hash1 != hash2 {
t.Errorf("HashPath() not deterministic: %s vs %s", hash1, hash2) t.Errorf("HashPath() not deterministic: %s vs %s", hash1, hash2)
@@ -276,9 +321,11 @@ func TestHashPath(t *testing.T) {
} }
func TestCacheKey(t *testing.T) { func TestCacheKey(t *testing.T) {
t.Parallel()
req1 := &ImageRequest{ req1 := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: testHostCDN,
SourcePath: "/photos/cat.jpg", SourcePath: testPathCat,
SourceQuery: "", SourceQuery: "",
Size: Size{Width: 800, Height: 600}, Size: Size{Width: 800, Height: 600},
Format: FormatWebP, Format: FormatWebP,
@@ -287,8 +334,8 @@ func TestCacheKey(t *testing.T) {
} }
req2 := &ImageRequest{ req2 := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: testHostCDN,
SourcePath: "/photos/cat.jpg", SourcePath: testPathCat,
SourceQuery: "", SourceQuery: "",
Size: Size{Width: 800, Height: 600}, Size: Size{Width: 800, Height: 600},
Format: FormatWebP, Format: FormatWebP,
@@ -311,8 +358,8 @@ func TestCacheKey(t *testing.T) {
// Different size should produce different key // Different size should produce different key
req3 := &ImageRequest{ req3 := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: testHostCDN,
SourcePath: "/photos/cat.jpg", SourcePath: testPathCat,
SourceQuery: "", SourceQuery: "",
Size: Size{Width: 400, Height: 300}, // Different size Size: Size{Width: 400, Height: 300}, // Different size
Format: FormatWebP, Format: FormatWebP,
@@ -327,8 +374,8 @@ func TestCacheKey(t *testing.T) {
// Different format should produce different key // Different format should produce different key
req4 := &ImageRequest{ req4 := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: testHostCDN,
SourcePath: "/photos/cat.jpg", SourcePath: testPathCat,
SourceQuery: "", SourceQuery: "",
Size: Size{Width: 800, Height: 600}, Size: Size{Width: 800, Height: 600},
Format: FormatPNG, // Different format Format: FormatPNG, // Different format
@@ -343,8 +390,8 @@ func TestCacheKey(t *testing.T) {
// Different quality should produce different key // Different quality should produce different key
req5 := &ImageRequest{ req5 := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: testHostCDN,
SourcePath: "/photos/cat.jpg", SourcePath: testPathCat,
SourceQuery: "", SourceQuery: "",
Size: Size{Width: 800, Height: 600}, Size: Size{Width: 800, Height: 600},
Format: FormatWebP, Format: FormatWebP,

View File

@@ -18,6 +18,14 @@ import (
"sneak.berlin/go/pixa/internal/httpfetcher" "sneak.berlin/go/pixa/internal/httpfetcher"
) )
// Shared test data literals, extracted as constants for goconst.
const (
testHostCDN = "cdn.example.com"
testHostExample = "example.com"
testPathCat = "/photos/cat.jpg"
testContentTypeJPEG = "image/jpeg"
)
// TestFixtures contains paths to test files in the mock filesystem. // TestFixtures contains paths to test files in the mock filesystem.
type TestFixtures struct { type TestFixtures struct {
// Valid image files // Valid image files
@@ -89,14 +97,16 @@ func generateTestJPEG(t *testing.T, width, height int, c color.Color) []byte {
t.Helper() t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height)) img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ { for y := range height {
for x := 0; x < width; x++ { for x := range width {
img.Set(x, y, c) img.Set(x, y, c)
} }
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85})
if err != nil {
t.Fatalf("failed to encode test JPEG: %v", err) t.Fatalf("failed to encode test JPEG: %v", err)
} }
@@ -108,14 +118,16 @@ func generateTestPNG(t *testing.T, width, height int, c color.Color) []byte {
t.Helper() t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height)) img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ { for y := range height {
for x := 0; x < width; x++ { for x := range width {
img.Set(x, y, c) img.Set(x, y, c)
} }
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
err := png.Encode(&buf, img)
if err != nil {
t.Fatalf("failed to encode test PNG: %v", err) t.Fatalf("failed to encode test PNG: %v", err)
} }
@@ -126,15 +138,20 @@ func generateTestPNG(t *testing.T, width, height int, c color.Color) []byte {
func generateTestGIF(t *testing.T, width, height int, c color.Color) []byte { func generateTestGIF(t *testing.T, width, height int, c color.Color) []byte {
t.Helper() t.Helper()
img := image.NewPaletted(image.Rect(0, 0, width, height), []color.Color{c, color.White}) img := image.NewPaletted(
for y := 0; y < height; y++ { image.Rect(0, 0, width, height),
for x := 0; x < width; x++ { []color.Color{c, color.White},
)
for y := range height {
for x := range width {
img.SetColorIndex(x, y, 0) img.SetColorIndex(x, y, 0)
} }
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := gif.Encode(&buf, img, nil); err != nil {
err := gif.Encode(&buf, img, nil)
if err != nil {
t.Fatalf("failed to encode test GIF: %v", err) t.Fatalf("failed to encode test GIF: %v", err)
} }
@@ -142,7 +159,9 @@ func generateTestGIF(t *testing.T, width, height int, c color.Color) []byte {
} }
// SetupTestService creates a Service with mock fetcher for testing. // SetupTestService creates a Service with mock fetcher for testing.
func SetupTestService(t *testing.T, opts ...TestServiceOption) (*Service, *TestFixtures) { func SetupTestService(
t *testing.T, opts ...TestServiceOption,
) (*Service, *TestFixtures) {
t.Helper() t.Helper()
mockFS, fixtures := NewTestFS(t) mockFS, fixtures := NewTestFS(t)
@@ -195,7 +214,8 @@ func setupServiceTestDB(t *testing.T) *sql.DB {
} }
// Use the real production schema via migrations // Use the real production schema via migrations
if err := database.ApplyMigrations(context.Background(), db, nil); err != nil { err = database.ApplyMigrations(context.Background(), db, nil)
if err != nil {
t.Fatalf("failed to apply migrations: %v", err) t.Fatalf("failed to apply migrations: %v", err)
} }

View File

@@ -40,7 +40,8 @@ type ParsedURL struct {
Format ImageFormat Format ImageFormat
} }
// ParseImagePath parses the path captured by chi's wildcard: <host>/<path>/<size>.<format> // ParseImagePath parses the path captured by chi's wildcard:
// <host>/<path>/<size>.<format>
// This is the primary entry point when using chi routing. // This is the primary entry point when using chi routing.
// Examples: // Examples:
// - cdn.example.com/photos/cat.jpg/800x600.webp // - cdn.example.com/photos/cat.jpg/800x600.webp
@@ -76,7 +77,8 @@ func ParseImageURL(urlPath string) (*ParsedURL, error) {
// parseImageComponents parses <host>/<path>/<size>.<format> structure. // parseImageComponents parses <host>/<path>/<size>.<format> structure.
func parseImageComponents(remainder string) (*ParsedURL, error) { func parseImageComponents(remainder string) (*ParsedURL, error) {
// Check for path traversal before any other processing // Check for path traversal before any other processing
if err := checkPathTraversal(remainder); err != nil { err := checkPathTraversal(remainder)
if err != nil {
return nil, err return nil, err
} }
@@ -102,6 +104,7 @@ func parseImageComponents(remainder string) (*ParsedURL, error) {
// Split host from path // Split host from path
// The first segment is the host, everything after is the path // The first segment is the host, everything after is the path
firstSlash := strings.Index(hostAndPath, "/") firstSlash := strings.Index(hostAndPath, "/")
var host, path, query string var host, path, query string
if firstSlash == -1 { if firstSlash == -1 {
@@ -181,8 +184,7 @@ func checkPathTraversal(path string) error {
// Also check for ".." as a path segment in the original path // Also check for ".." as a path segment in the original path
// This catches cases where the path hasn't been normalized // This catches cases where the path hasn't been normalized
segments := strings.Split(path, "/") for seg := range strings.SplitSeq(path, "/") {
for _, seg := range segments {
// URL decode the segment // URL decode the segment
decodedSeg, _ := url.PathUnescape(seg) decodedSeg, _ := url.PathUnescape(seg)
decodedSeg = strings.ReplaceAll(decodedSeg, "\\", "/") decodedSeg = strings.ReplaceAll(decodedSeg, "\\", "/")
@@ -202,8 +204,10 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
return Size{}, "", ErrInvalidSize return Size{}, "", ErrInvalidSize
} }
var size Size var (
var formatStr string size Size
formatStr string
)
if matches[4] == "orig" { if matches[4] == "orig" {
// "orig.format" pattern // "orig.format" pattern

View File

@@ -1,93 +1,124 @@
package imgcache package imgcache
import ( import (
"errors"
"testing" "testing"
) )
// assertParsedURL compares all fields of a parsed URL against the
// expected value.
func assertParsedURL(t *testing.T, got, want *ParsedURL) {
t.Helper()
if got.Host != want.Host {
t.Errorf("Host = %q, want %q", got.Host, want.Host)
}
if got.Path != want.Path {
t.Errorf("Path = %q, want %q", got.Path, want.Path)
}
if got.Query != want.Query {
t.Errorf("Query = %q, want %q", got.Query, want.Query)
}
if got.Size != want.Size {
t.Errorf("Size = %v, want %v", got.Size, want.Size)
}
if got.Format != want.Format {
t.Errorf("Format = %q, want %q", got.Format, want.Format)
}
}
func TestParseImageURL(t *testing.T) { func TestParseImageURL(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
name string name string
input string input string
want *ParsedURL want *ParsedURL
wantErr error
}{ }{
{ {
name: "basic path with size", name: "basic path with size",
input: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp", input: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp",
want: &ParsedURL{ want: &ParsedURL{
Host: "cdn.example.com", Host: testHostCDN, Path: testPathCat,
Path: "/photos/cat.jpg", Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
Query: "",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
}, },
}, },
{ {
name: "original size with 0x0", name: "original size with 0x0",
input: "/v1/image/cdn.example.com/photos/cat.jpg/0x0.jpeg", input: "/v1/image/cdn.example.com/photos/cat.jpg/0x0.jpeg",
want: &ParsedURL{ want: &ParsedURL{
Host: "cdn.example.com", Host: testHostCDN, Path: testPathCat,
Path: "/photos/cat.jpg", Size: Size{Width: 0, Height: 0}, Format: FormatJPEG,
Query: "",
Size: Size{Width: 0, Height: 0},
Format: FormatJPEG,
}, },
}, },
{ {
name: "original size with orig keyword", name: "original size with orig keyword",
input: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png", input: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
want: &ParsedURL{ want: &ParsedURL{
Host: "cdn.example.com", Host: testHostCDN, Path: testPathCat,
Path: "/photos/cat.jpg", Size: Size{Width: 0, Height: 0}, Format: FormatPNG,
Query: "",
Size: Size{Width: 0, Height: 0},
Format: FormatPNG,
}, },
}, },
{ {
name: "path with query string", name: "path with query string",
input: "/v1/image/cdn.example.com/photos/cat.jpg?arg1=val1&arg2=val2/800x600.webp", input: "/v1/image/cdn.example.com/photos/cat.jpg?arg1=val1&arg2=val2/800x600.webp",
want: &ParsedURL{ want: &ParsedURL{
Host: "cdn.example.com", Host: testHostCDN, Path: testPathCat, Query: "arg1=val1&arg2=val2",
Path: "/photos/cat.jpg", Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
Query: "arg1=val1&arg2=val2",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
}, },
}, },
{ {
name: "deep nested path", name: "deep nested path",
input: "/v1/image/cdn.example.com/a/b/c/d/image.jpg/1920x1080.avif", input: "/v1/image/cdn.example.com/a/b/c/d/image.jpg/1920x1080.avif",
want: &ParsedURL{ want: &ParsedURL{
Host: "cdn.example.com", Host: testHostCDN, Path: "/a/b/c/d/image.jpg",
Path: "/a/b/c/d/image.jpg", Size: Size{Width: 1920, Height: 1080}, Format: FormatAVIF,
Query: "",
Size: Size{Width: 1920, Height: 1080},
Format: FormatAVIF,
}, },
}, },
{ {
name: "jpg alias for jpeg", name: "jpg alias for jpeg",
input: "/v1/image/example.com/img.png/100x100.jpg", input: "/v1/image/example.com/img.png/100x100.jpg",
want: &ParsedURL{ want: &ParsedURL{
Host: "example.com", Host: testHostExample, Path: "/img.png",
Path: "/img.png", Size: Size{Width: 100, Height: 100}, Format: FormatJPEG,
Query: "",
Size: Size{Width: 100, Height: 100},
Format: FormatJPEG,
}, },
}, },
{ {
name: "gif format", name: "gif format",
input: "/v1/image/example.com/animated.gif/200x200.gif", input: "/v1/image/example.com/animated.gif/200x200.gif",
want: &ParsedURL{ want: &ParsedURL{
Host: "example.com", Host: testHostExample, Path: "/animated.gif",
Path: "/animated.gif", Size: Size{Width: 200, Height: 200}, Format: FormatGIF,
Query: "",
Size: Size{Width: 200, Height: 200},
Format: FormatGIF,
}, },
}, },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := ParseImageURL(tt.input)
if err != nil {
t.Fatalf("ParseImageURL() unexpected error = %v", err)
}
assertParsedURL(t, got, tt.want)
})
}
}
func TestParseImageURL_Errors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantErr error
}{
{ {
name: "missing prefix", name: "missing prefix",
input: "/image/cdn.example.com/photo.jpg/800x600.webp", input: "/image/cdn.example.com/photo.jpg/800x600.webp",
@@ -122,47 +153,23 @@ func TestParseImageURL(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got, err := ParseImageURL(tt.input) t.Parallel()
if tt.wantErr != nil { _, err := ParseImageURL(tt.input)
if err == nil { if err == nil {
t.Errorf("ParseImageURL() error = nil, wantErr %v", tt.wantErr) t.Fatalf("ParseImageURL() error = nil, wantErr %v", tt.wantErr)
return
}
if !errorIs(err, tt.wantErr) {
t.Errorf("ParseImageURL() error = %v, wantErr %v", err, tt.wantErr)
}
return
} }
if err != nil { if !errorIs(err, tt.wantErr) {
t.Errorf("ParseImageURL() unexpected error = %v", err) t.Errorf("ParseImageURL() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got.Host != tt.want.Host {
t.Errorf("Host = %q, want %q", got.Host, tt.want.Host)
}
if got.Path != tt.want.Path {
t.Errorf("Path = %q, want %q", got.Path, tt.want.Path)
}
if got.Query != tt.want.Query {
t.Errorf("Query = %q, want %q", got.Query, tt.want.Query)
}
if got.Size != tt.want.Size {
t.Errorf("Size = %v, want %v", got.Size, tt.want.Size)
}
if got.Format != tt.want.Format {
t.Errorf("Format = %q, want %q", got.Format, tt.want.Format)
} }
}) })
} }
} }
func TestParseImagePath(t *testing.T) { func TestParseImagePath(t *testing.T) {
t.Parallel()
// ParseImagePath is for chi wildcard capture (no /v1/image/ prefix) // ParseImagePath is for chi wildcard capture (no /v1/image/ prefix)
tests := []struct { tests := []struct {
name string name string
@@ -174,8 +181,8 @@ func TestParseImagePath(t *testing.T) {
name: "chi wildcard capture", name: "chi wildcard capture",
input: "cdn.example.com/photos/cat.jpg/800x600.webp", input: "cdn.example.com/photos/cat.jpg/800x600.webp",
want: &ParsedURL{ want: &ParsedURL{
Host: "cdn.example.com", Host: testHostCDN,
Path: "/photos/cat.jpg", Path: testPathCat,
Size: Size{Width: 800, Height: 600}, Size: Size{Width: 800, Height: 600},
Format: FormatWebP, Format: FormatWebP,
}, },
@@ -184,8 +191,8 @@ func TestParseImagePath(t *testing.T) {
name: "with leading slash from chi", name: "with leading slash from chi",
input: "/cdn.example.com/photos/cat.jpg/800x600.webp", input: "/cdn.example.com/photos/cat.jpg/800x600.webp",
want: &ParsedURL{ want: &ParsedURL{
Host: "cdn.example.com", Host: testHostCDN,
Path: "/photos/cat.jpg", Path: testPathCat,
Size: Size{Width: 800, Height: 600}, Size: Size{Width: 800, Height: 600},
Format: FormatWebP, Format: FormatWebP,
}, },
@@ -194,35 +201,30 @@ func TestParseImagePath(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := ParseImagePath(tt.input) got, err := ParseImagePath(tt.input)
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("ParseImagePath() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("ParseImagePath() error = %v, wantErr %v", err, tt.wantErr)
return return
} }
if err != nil { if err != nil {
return return
} }
if got.Host != tt.want.Host {
t.Errorf("Host = %q, want %q", got.Host, tt.want.Host) assertParsedURL(t, got, tt.want)
}
if got.Path != tt.want.Path {
t.Errorf("Path = %q, want %q", got.Path, tt.want.Path)
}
if got.Size != tt.want.Size {
t.Errorf("Size = %v, want %v", got.Size, tt.want.Size)
}
if got.Format != tt.want.Format {
t.Errorf("Format = %q, want %q", got.Format, tt.want.Format)
}
}) })
} }
} }
func TestParsedURL_ToImageRequest(t *testing.T) { func TestParsedURL_ToImageRequest(t *testing.T) {
t.Parallel()
parsed := &ParsedURL{ parsed := &ParsedURL{
Host: "cdn.example.com", Host: testHostCDN,
Path: "/photos/cat.jpg", Path: testPathCat,
Query: "version=2", Query: "version=2",
Size: Size{Width: 800, Height: 600}, Size: Size{Width: 800, Height: 600},
Format: FormatWebP, Format: FormatWebP,
@@ -233,21 +235,27 @@ func TestParsedURL_ToImageRequest(t *testing.T) {
if req.SourceHost != parsed.Host { if req.SourceHost != parsed.Host {
t.Errorf("SourceHost = %q, want %q", req.SourceHost, parsed.Host) t.Errorf("SourceHost = %q, want %q", req.SourceHost, parsed.Host)
} }
if req.SourcePath != parsed.Path { if req.SourcePath != parsed.Path {
t.Errorf("SourcePath = %q, want %q", req.SourcePath, parsed.Path) t.Errorf("SourcePath = %q, want %q", req.SourcePath, parsed.Path)
} }
if req.SourceQuery != parsed.Query { if req.SourceQuery != parsed.Query {
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, parsed.Query) t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, parsed.Query)
} }
if req.Size != parsed.Size { if req.Size != parsed.Size {
t.Errorf("Size = %v, want %v", req.Size, parsed.Size) t.Errorf("Size = %v, want %v", req.Size, parsed.Size)
} }
if req.Format != parsed.Format { if req.Format != parsed.Format {
t.Errorf("Format = %q, want %q", req.Format, parsed.Format) t.Errorf("Format = %q, want %q", req.Format, parsed.Format)
} }
} }
func TestParseImageURL_PathTraversal(t *testing.T) { func TestParseImageURL_PathTraversal(t *testing.T) {
t.Parallel()
// All path traversal attempts should be rejected // All path traversal attempts should be rejected
tests := []struct { tests := []struct {
name string name string
@@ -293,12 +301,14 @@ func TestParseImageURL_PathTraversal(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := ParseImageURL(tt.input) _, err := ParseImageURL(tt.input)
if err == nil { if err == nil {
t.Error("ParseImageURL() should reject path traversal attempts") t.Error("ParseImageURL() should reject path traversal attempts")
} }
if err != ErrPathTraversal { if !errors.Is(err, ErrPathTraversal) {
t.Errorf("ParseImageURL() error = %v, want ErrPathTraversal", err) t.Errorf("ParseImageURL() error = %v, want ErrPathTraversal", err)
} }
}) })
@@ -306,6 +316,8 @@ func TestParseImageURL_PathTraversal(t *testing.T) {
} }
func TestParseImagePath_PathTraversal(t *testing.T) { func TestParseImagePath_PathTraversal(t *testing.T) {
t.Parallel()
// Test path traversal via ParseImagePath (chi wildcard) // Test path traversal via ParseImagePath (chi wildcard)
tests := []struct { tests := []struct {
name string name string
@@ -323,12 +335,14 @@ func TestParseImagePath_PathTraversal(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := ParseImagePath(tt.input) _, err := ParseImagePath(tt.input)
if err == nil { if err == nil {
t.Error("ParseImagePath() should reject path traversal attempts") t.Error("ParseImagePath() should reject path traversal attempts")
} }
if err != ErrPathTraversal { if !errors.Is(err, ErrPathTraversal) {
t.Errorf("ParseImagePath() error = %v, want ErrPathTraversal", err) t.Errorf("ParseImagePath() error = %v, want ErrPathTraversal", err)
} }
}) })
@@ -337,7 +351,7 @@ func TestParseImagePath_PathTraversal(t *testing.T) {
// errorIs checks if err matches target (handles wrapped errors). // errorIs checks if err matches target (handles wrapped errors).
func errorIs(err, target error) bool { func errorIs(err, target error) bool {
if err == target { if errors.Is(err, target) {
return true return true
} }
// Check if error message contains target message for wrapped errors // Check if error message contains target message for wrapped errors

View File

@@ -15,6 +15,7 @@ import (
// Params defines dependencies for Logger. // Params defines dependencies for Logger.
type Params struct { type Params struct {
fx.In fx.In
Globals *globals.Globals Globals *globals.Globals
} }

View File

@@ -46,6 +46,10 @@ const (
// MinMagicBytes is the minimum number of bytes needed to detect format. // MinMagicBytes is the minimum number of bytes needed to detect format.
const MinMagicBytes = 12 const MinMagicBytes = 12
// mimeOctetStream is the fallback MIME type for formats without a
// specific MIME type.
const mimeOctetStream = "application/octet-stream"
// Magic byte signatures for supported formats. // Magic byte signatures for supported formats.
// These are effectively constants but Go doesn't support const slices. // These are effectively constants but Go doesn't support const slices.
// //
@@ -190,14 +194,17 @@ func IsSupportedMIMEType(mimeType string) bool {
func PeekAndValidate(r io.Reader, declaredType string) (io.Reader, error) { func PeekAndValidate(r io.Reader, declaredType string) (io.Reader, error) {
// Read minimum bytes for detection // Read minimum bytes for detection
buf := make([]byte, MinMagicBytes) buf := make([]byte, MinMagicBytes)
n, err := io.ReadFull(r, buf) n, err := io.ReadFull(r, buf)
if err != nil && err != io.ErrUnexpectedEOF { if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) {
return nil, err return nil, err
} }
buf = buf[:n] buf = buf[:n]
// Validate magic bytes // Validate magic bytes
if err := ValidateMagicBytes(buf, declaredType); err != nil { err = ValidateMagicBytes(buf, declaredType)
if err != nil {
return nil, err return nil, err
} }
@@ -219,6 +226,9 @@ func MIMEToImageFormat(mimeType string) (ImageFormat, bool) {
return FormatGIF, true return FormatGIF, true
case MIMETypeAVIF: case MIMETypeAVIF:
return FormatAVIF, true return FormatAVIF, true
case MIMETypeSVG:
// SVG has no corresponding output format.
return "", false
default: default:
return "", false return "", false
} }
@@ -237,7 +247,10 @@ func ImageFormatToMIME(format ImageFormat) string {
return string(MIMETypeGIF) return string(MIMETypeGIF)
case FormatAVIF: case FormatAVIF:
return string(MIMETypeAVIF) return string(MIMETypeAVIF)
case FormatOriginal:
// Original format passes content through unchanged.
return mimeOctetStream
default: default:
return "application/octet-stream" return mimeOctetStream
} }
} }

View File

@@ -2,121 +2,90 @@ package magic
import ( import (
"bytes" "bytes"
"errors"
"io" "io"
"slices"
"strings" "strings"
"testing" "testing"
) )
// Shared test fixture strings.
const (
testNameEmpty = "empty"
testMIMEJPEG = "image/jpeg"
testMIMEJPEGParams = "image/jpeg; charset=utf-8"
testMIMEPNG = "image/png"
testMIMEWebP = "image/webp"
testMIMEGIF = "image/gif"
testMIMEAVIF = "image/avif"
)
// pad appends zero bytes so data is comfortably above MinMagicBytes.
func pad(b ...byte) []byte {
return append(b, make([]byte, 100)...)
}
func TestDetectFormat(t *testing.T) { func TestDetectFormat(t *testing.T) {
t.Parallel()
jpeg := pad(0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01)
png := pad(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D)
gif87a := pad(0x47, 0x49, 0x46, 0x38, 0x37, 0x61, 0, 0, 0, 0, 0, 0)
gif89a := pad(0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0, 0, 0, 0, 0, 0)
// RIFF + size placeholder + WEBP
webp := pad(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50)
// box size + ftyp + brand
avif := pad(0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66)
avis := pad(0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x73)
tests := []struct { tests := []struct {
name string name string
data []byte data []byte
wantMIME MIMEType wantMIME MIMEType
wantErr error wantErr error
}{ }{
{ {name: "JPEG", data: jpeg, wantMIME: MIMETypeJPEG},
name: "JPEG", {name: "PNG", data: png, wantMIME: MIMETypePNG},
data: append([]byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01}, make([]byte, 100)...), {name: "GIF87a", data: gif87a, wantMIME: MIMETypeGIF},
wantMIME: MIMETypeJPEG, {name: "GIF89a", data: gif89a, wantMIME: MIMETypeGIF},
wantErr: nil, {name: "WebP", data: webp, wantMIME: MIMETypeWebP},
}, {name: "AVIF", data: avif, wantMIME: MIMETypeAVIF},
{ {name: "AVIF sequence", data: avis, wantMIME: MIMETypeAVIF},
name: "PNG",
data: append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D}, make([]byte, 100)...),
wantMIME: MIMETypePNG,
wantErr: nil,
},
{
name: "GIF87a",
data: append([]byte{0x47, 0x49, 0x46, 0x38, 0x37, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, make([]byte, 100)...),
wantMIME: MIMETypeGIF,
wantErr: nil,
},
{
name: "GIF89a",
data: append([]byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, make([]byte, 100)...),
wantMIME: MIMETypeGIF,
wantErr: nil,
},
{
name: "WebP",
data: append([]byte{
0x52, 0x49, 0x46, 0x46, // RIFF
0x00, 0x00, 0x00, 0x00, // file size (placeholder)
0x57, 0x45, 0x42, 0x50, // WEBP
}, make([]byte, 100)...),
wantMIME: MIMETypeWebP,
wantErr: nil,
},
{
name: "AVIF",
data: append([]byte{
0x00, 0x00, 0x00, 0x1C, // box size
0x66, 0x74, 0x79, 0x70, // ftyp
0x61, 0x76, 0x69, 0x66, // avif brand
}, make([]byte, 100)...),
wantMIME: MIMETypeAVIF,
wantErr: nil,
},
{
name: "AVIF sequence",
data: append([]byte{
0x00, 0x00, 0x00, 0x1C, // box size
0x66, 0x74, 0x79, 0x70, // ftyp
0x61, 0x76, 0x69, 0x73, // avis brand
}, make([]byte, 100)...),
wantMIME: MIMETypeAVIF,
wantErr: nil,
},
{ {
name: "SVG with XML declaration", name: "SVG with XML declaration",
data: []byte(`<?xml version="1.0"?><svg></svg>`), data: []byte(`<?xml version="1.0"?><svg></svg>`),
wantMIME: MIMETypeSVG, wantMIME: MIMETypeSVG,
wantErr: nil,
}, },
{ {
name: "SVG without declaration", name: "SVG without declaration",
data: []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`), data: []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
wantMIME: MIMETypeSVG, wantMIME: MIMETypeSVG,
wantErr: nil,
}, },
{ {
name: "SVG with whitespace", name: "SVG with whitespace",
data: []byte(` <?xml version="1.0"?><svg></svg>`), data: []byte(` <?xml version="1.0"?><svg></svg>`),
wantMIME: MIMETypeSVG, wantMIME: MIMETypeSVG,
wantErr: nil,
}, },
{ {
name: "SVG with BOM", name: "SVG with BOM",
data: append([]byte{0xEF, 0xBB, 0xBF}, []byte(`<svg></svg>`)...), data: append([]byte{0xEF, 0xBB, 0xBF}, []byte(`<svg></svg>`)...),
wantMIME: MIMETypeSVG, wantMIME: MIMETypeSVG,
wantErr: nil,
}, },
{ {
name: "unknown format", name: "unknown format",
data: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, data: make([]byte, MinMagicBytes),
wantMIME: "", wantErr: ErrUnknownFormat,
wantErr: ErrUnknownFormat,
},
{
name: "too short",
data: []byte{0xFF, 0xD8},
wantMIME: "",
wantErr: ErrNotEnoughData,
},
{
name: "empty",
data: []byte{},
wantMIME: "",
wantErr: ErrNotEnoughData,
}, },
{name: "too short", data: []byte{0xFF, 0xD8}, wantErr: ErrNotEnoughData},
{name: testNameEmpty, data: []byte{}, wantErr: ErrNotEnoughData},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got, err := DetectFormat(tt.data) t.Parallel()
if err != tt.wantErr { got, err := DetectFormat(tt.data)
if !errors.Is(err, tt.wantErr) {
t.Errorf("DetectFormat() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("DetectFormat() error = %v, wantErr %v", err, tt.wantErr)
return return
@@ -130,8 +99,10 @@ func TestDetectFormat(t *testing.T) {
} }
func TestValidateMagicBytes(t *testing.T) { func TestValidateMagicBytes(t *testing.T) {
jpegData := append([]byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01}, make([]byte, 100)...) t.Parallel()
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D}, make([]byte, 100)...)
jpegData := pad(0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01)
pngData := pad(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D)
tests := []struct { tests := []struct {
name string name string
@@ -142,40 +113,42 @@ func TestValidateMagicBytes(t *testing.T) {
{ {
name: "matching JPEG", name: "matching JPEG",
data: jpegData, data: jpegData,
declaredType: "image/jpeg", declaredType: testMIMEJPEG,
wantErr: nil, wantErr: nil,
}, },
{ {
name: "matching JPEG with params", name: "matching JPEG with params",
data: jpegData, data: jpegData,
declaredType: "image/jpeg; charset=utf-8", declaredType: testMIMEJPEGParams,
wantErr: nil, wantErr: nil,
}, },
{ {
name: "matching PNG", name: "matching PNG",
data: pngData, data: pngData,
declaredType: "image/png", declaredType: testMIMEPNG,
wantErr: nil, wantErr: nil,
}, },
{ {
name: "mismatched type", name: "mismatched type",
data: jpegData, data: jpegData,
declaredType: "image/png", declaredType: testMIMEPNG,
wantErr: ErrMagicByteMismatch, wantErr: ErrMagicByteMismatch,
}, },
{ {
name: "unknown data", name: "unknown data",
data: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, data: make([]byte, MinMagicBytes),
declaredType: "image/jpeg", declaredType: testMIMEJPEG,
wantErr: ErrUnknownFormat, wantErr: ErrUnknownFormat,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := ValidateMagicBytes(tt.data, tt.declaredType) err := ValidateMagicBytes(tt.data, tt.declaredType)
if err != tt.wantErr { if !errors.Is(err, tt.wantErr) {
t.Errorf("ValidateMagicBytes() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("ValidateMagicBytes() error = %v, wantErr %v", err, tt.wantErr)
} }
}) })
@@ -183,27 +156,31 @@ func TestValidateMagicBytes(t *testing.T) {
} }
func TestIsSupportedMIMEType(t *testing.T) { func TestIsSupportedMIMEType(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
mimeType string mimeType string
want bool want bool
}{ }{
{"image/jpeg", true}, {testMIMEJPEG, true},
{"image/png", true}, {testMIMEPNG, true},
{"image/webp", true}, {testMIMEWebP, true},
{"image/gif", true}, {testMIMEGIF, true},
{"image/avif", true}, {testMIMEAVIF, true},
{"image/svg+xml", true}, {"image/svg+xml", true},
{"IMAGE/JPEG", true}, {"IMAGE/JPEG", true},
{"image/jpeg; charset=utf-8", true}, {testMIMEJPEGParams, true},
{"image/tiff", false}, {"image/tiff", false},
{"image/bmp", false}, {"image/bmp", false},
{"application/octet-stream", false}, {mimeOctetStream, false},
{"text/plain", false}, {"text/plain", false},
{"", false}, {"", false},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.mimeType, func(t *testing.T) { t.Run(tt.mimeType, func(t *testing.T) {
t.Parallel()
if got := IsSupportedMIMEType(tt.mimeType); got != tt.want { if got := IsSupportedMIMEType(tt.mimeType); got != tt.want {
t.Errorf("IsSupportedMIMEType(%q) = %v, want %v", tt.mimeType, got, tt.want) t.Errorf("IsSupportedMIMEType(%q) = %v, want %v", tt.mimeType, got, tt.want)
} }
@@ -212,8 +189,16 @@ func TestIsSupportedMIMEType(t *testing.T) {
} }
func TestPeekAndValidate(t *testing.T) { func TestPeekAndValidate(t *testing.T) {
jpegData := append([]byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01}, []byte("rest of jpeg data")...) t.Parallel()
pngData := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D}, []byte("rest of png data")...)
jpegMagic := []byte{
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01,
}
pngMagic := []byte{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D,
}
jpegData := slices.Concat(jpegMagic, []byte("rest of jpeg data"))
pngData := slices.Concat(pngMagic, []byte("rest of png data"))
tests := []struct { tests := []struct {
name string name string
@@ -225,30 +210,32 @@ func TestPeekAndValidate(t *testing.T) {
{ {
name: "valid JPEG", name: "valid JPEG",
data: jpegData, data: jpegData,
declaredType: "image/jpeg", declaredType: testMIMEJPEG,
wantErr: false, wantErr: false,
wantData: jpegData, wantData: jpegData,
}, },
{ {
name: "valid PNG", name: "valid PNG",
data: pngData, data: pngData,
declaredType: "image/png", declaredType: testMIMEPNG,
wantErr: false, wantErr: false,
wantData: pngData, wantData: pngData,
}, },
{ {
name: "mismatched type", name: "mismatched type",
data: jpegData, data: jpegData,
declaredType: "image/png", declaredType: testMIMEPNG,
wantErr: true, wantErr: true,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
r := bytes.NewReader(tt.data) t.Parallel()
result, err := PeekAndValidate(r, tt.declaredType)
r := bytes.NewReader(tt.data)
result, err := PeekAndValidate(r, tt.declaredType)
if tt.wantErr { if tt.wantErr {
if err == nil { if err == nil {
t.Error("PeekAndValidate() expected error, got nil") t.Error("PeekAndValidate() expected error, got nil")
@@ -272,23 +259,28 @@ func TestPeekAndValidate(t *testing.T) {
} }
if !bytes.Equal(got, tt.wantData) { if !bytes.Equal(got, tt.wantData) {
t.Errorf("PeekAndValidate() data mismatch: got %d bytes, want %d bytes", len(got), len(tt.wantData)) t.Errorf(
"PeekAndValidate() data mismatch: got %d bytes, want %d bytes",
len(got), len(tt.wantData),
)
} }
}) })
} }
} }
func TestMIMEToImageFormat(t *testing.T) { func TestMIMEToImageFormat(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
mimeType string mimeType string
wantFormat ImageFormat wantFormat ImageFormat
wantOk bool wantOk bool
}{ }{
{"image/jpeg", FormatJPEG, true}, {testMIMEJPEG, FormatJPEG, true},
{"image/png", FormatPNG, true}, {testMIMEPNG, FormatPNG, true},
{"image/webp", FormatWebP, true}, {testMIMEWebP, FormatWebP, true},
{"image/gif", FormatGIF, true}, {testMIMEGIF, FormatGIF, true},
{"image/avif", FormatAVIF, true}, {testMIMEAVIF, FormatAVIF, true},
{"image/svg+xml", "", false}, // SVG doesn't convert to ImageFormat {"image/svg+xml", "", false}, // SVG doesn't convert to ImageFormat
{"image/tiff", "", false}, {"image/tiff", "", false},
{"text/plain", "", false}, {"text/plain", "", false},
@@ -296,6 +288,8 @@ func TestMIMEToImageFormat(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.mimeType, func(t *testing.T) { t.Run(tt.mimeType, func(t *testing.T) {
t.Parallel()
got, ok := MIMEToImageFormat(tt.mimeType) got, ok := MIMEToImageFormat(tt.mimeType)
if ok != tt.wantOk { if ok != tt.wantOk {
@@ -310,21 +304,25 @@ func TestMIMEToImageFormat(t *testing.T) {
} }
func TestImageFormatToMIME(t *testing.T) { func TestImageFormatToMIME(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
format ImageFormat format ImageFormat
wantMIME string wantMIME string
}{ }{
{FormatJPEG, "image/jpeg"}, {FormatJPEG, testMIMEJPEG},
{FormatPNG, "image/png"}, {FormatPNG, testMIMEPNG},
{FormatWebP, "image/webp"}, {FormatWebP, testMIMEWebP},
{FormatGIF, "image/gif"}, {FormatGIF, testMIMEGIF},
{FormatAVIF, "image/avif"}, {FormatAVIF, testMIMEAVIF},
{FormatOriginal, "application/octet-stream"}, {FormatOriginal, mimeOctetStream},
{"unknown", "application/octet-stream"}, {"unknown", mimeOctetStream},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(string(tt.format), func(t *testing.T) { t.Run(string(tt.format), func(t *testing.T) {
t.Parallel()
got := ImageFormatToMIME(tt.format) got := ImageFormatToMIME(tt.format)
if got != tt.wantMIME { if got != tt.wantMIME {
@@ -335,19 +333,23 @@ func TestImageFormatToMIME(t *testing.T) {
} }
func TestNormalizeMIMEType(t *testing.T) { func TestNormalizeMIMEType(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
input string input string
want string want string
}{ }{
{"image/jpeg", "image/jpeg"}, {testMIMEJPEG, testMIMEJPEG},
{"IMAGE/JPEG", "image/jpeg"}, {"IMAGE/JPEG", testMIMEJPEG},
{"image/jpeg; charset=utf-8", "image/jpeg"}, {testMIMEJPEGParams, testMIMEJPEG},
{" image/jpeg ", "image/jpeg"}, {" image/jpeg ", testMIMEJPEG},
{"image/jpeg; boundary=something", "image/jpeg"}, {"image/jpeg; boundary=something", testMIMEJPEG},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) { t.Run(tt.input, func(t *testing.T) {
t.Parallel()
got := normalizeMIMEType(tt.input) got := normalizeMIMEType(tt.input)
if got != tt.want { if got != tt.want {
@@ -358,6 +360,8 @@ func TestNormalizeMIMEType(t *testing.T) {
} }
func TestDetectSVG(t *testing.T) { func TestDetectSVG(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
name string name string
data string data string
@@ -365,17 +369,24 @@ func TestDetectSVG(t *testing.T) {
}{ }{
{"xml declaration", `<?xml version="1.0"?><svg></svg>`, true}, {"xml declaration", `<?xml version="1.0"?><svg></svg>`, true},
{"svg element", `<svg xmlns="http://www.w3.org/2000/svg"></svg>`, true}, {"svg element", `<svg xmlns="http://www.w3.org/2000/svg"></svg>`, true},
{"doctype", `<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">`, true}, {
"doctype",
`<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" ` +
`"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">`,
true,
},
{"with whitespace", ` {"with whitespace", `
<?xml version="1.0"?><svg></svg>`, true}, <?xml version="1.0"?><svg></svg>`, true},
{"uppercase", `<SVG></SVG>`, true}, {"uppercase", `<SVG></SVG>`, true},
{"not svg", `<html></html>`, false}, {"not svg", `<html></html>`, false},
{"random text", `hello world`, false}, {"random text", `hello world`, false},
{"empty", ``, false}, {testNameEmpty, ``, false},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := detectSVG([]byte(tt.data)) got := detectSVG([]byte(tt.data))
if got != tt.want { if got != tt.want {
@@ -386,6 +397,8 @@ func TestDetectSVG(t *testing.T) {
} }
func TestSkipBOM(t *testing.T) { func TestSkipBOM(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
name string name string
data []byte data []byte
@@ -393,13 +406,15 @@ func TestSkipBOM(t *testing.T) {
}{ }{
{"with BOM", []byte{0xEF, 0xBB, 0xBF, 'h', 'e', 'l', 'l', 'o'}, []byte("hello")}, {"with BOM", []byte{0xEF, 0xBB, 0xBF, 'h', 'e', 'l', 'l', 'o'}, []byte("hello")},
{"without BOM", []byte("hello"), []byte("hello")}, {"without BOM", []byte("hello"), []byte("hello")},
{"empty", []byte{}, []byte{}}, {testNameEmpty, []byte{}, []byte{}},
{"only BOM", []byte{0xEF, 0xBB, 0xBF}, []byte{}}, {"only BOM", []byte{0xEF, 0xBB, 0xBF}, []byte{}},
{"partial BOM", []byte{0xEF, 0xBB}, []byte{0xEF, 0xBB}}, {"partial BOM", []byte{0xEF, 0xBB}, []byte{0xEF, 0xBB}},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := skipBOM(tt.data) got := skipBOM(tt.data)
if !bytes.Equal(got, tt.want) { if !bytes.Equal(got, tt.want) {
@@ -410,6 +425,8 @@ func TestSkipBOM(t *testing.T) {
} }
func TestRealWorldSVGPatterns(t *testing.T) { func TestRealWorldSVGPatterns(t *testing.T) {
t.Parallel()
// Test various real-world SVG patterns // Test various real-world SVG patterns
svgPatterns := []string{ svgPatterns := []string{
`<?xml version="1.0" encoding="UTF-8"?> `<?xml version="1.0" encoding="UTF-8"?>
@@ -419,7 +436,8 @@ func TestRealWorldSVGPatterns(t *testing.T) {
`<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7l10 5 10-5-10-5z"/> <path d="M12 2L2 7l10 5 10-5-10-5z"/>
</svg>`, </svg>`,
`<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> `<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" ` +
`"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">` + `
<svg xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg">
</svg>`, </svg>`,
} }
@@ -445,6 +463,8 @@ func TestRealWorldSVGPatterns(t *testing.T) {
} }
func TestDetectFormatRIFFNotWebP(t *testing.T) { func TestDetectFormatRIFFNotWebP(t *testing.T) {
t.Parallel()
// RIFF container but not WebP (e.g., WAV file) // RIFF container but not WebP (e.g., WAV file)
wavData := []byte{ wavData := []byte{
0x52, 0x49, 0x46, 0x46, // RIFF 0x52, 0x49, 0x46, 0x46, // RIFF
@@ -453,12 +473,14 @@ func TestDetectFormatRIFFNotWebP(t *testing.T) {
} }
_, err := DetectFormat(wavData) _, err := DetectFormat(wavData)
if err != ErrUnknownFormat { if !errors.Is(err, ErrUnknownFormat) {
t.Errorf("DetectFormat(WAV) error = %v, want %v", err, ErrUnknownFormat) t.Errorf("DetectFormat(WAV) error = %v, want %v", err, ErrUnknownFormat)
} }
} }
func TestDetectFormatFtypNotAVIF(t *testing.T) { func TestDetectFormatFtypNotAVIF(t *testing.T) {
t.Parallel()
// ftyp container but not AVIF (e.g., MP4) // ftyp container but not AVIF (e.g., MP4)
mp4Data := []byte{ mp4Data := []byte{
0x00, 0x00, 0x00, 0x1C, // box size 0x00, 0x00, 0x00, 0x1C, // box size
@@ -467,20 +489,24 @@ func TestDetectFormatFtypNotAVIF(t *testing.T) {
} }
_, err := DetectFormat(mp4Data) _, err := DetectFormat(mp4Data)
if err != ErrUnknownFormat { if !errors.Is(err, ErrUnknownFormat) {
t.Errorf("DetectFormat(MP4) error = %v, want %v", err, ErrUnknownFormat) t.Errorf("DetectFormat(MP4) error = %v, want %v", err, ErrUnknownFormat)
} }
} }
func TestPeekAndValidatePreservesReader(t *testing.T) { func TestPeekAndValidatePreservesReader(t *testing.T) {
// Ensure that after PeekAndValidate, we can read the complete original content t.Parallel()
// Ensure that after PeekAndValidate, we can read the complete
// original content
originalContent := append( originalContent := append(
[]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D}, []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D},
[]byte(strings.Repeat("PNG IDAT chunk data here ", 100))..., []byte(strings.Repeat("PNG IDAT chunk data here ", 100))...,
) )
r := bytes.NewReader(originalContent) r := bytes.NewReader(originalContent)
validated, err := PeekAndValidate(r, "image/png")
validated, err := PeekAndValidate(r, testMIMEPNG)
if err != nil { if err != nil {
t.Fatalf("PeekAndValidate() error = %v", err) t.Fatalf("PeekAndValidate() error = %v", err)
} }
@@ -492,6 +518,9 @@ func TestPeekAndValidatePreservesReader(t *testing.T) {
} }
if !bytes.Equal(got, originalContent) { if !bytes.Equal(got, originalContent) {
t.Errorf("Content mismatch: got %d bytes, want %d bytes", len(got), len(originalContent)) t.Errorf(
"Content mismatch: got %d bytes, want %d bytes",
len(got), len(originalContent),
)
} }
} }

View File

@@ -24,6 +24,7 @@ const CORSMaxAgeSeconds = 86400
// Params defines dependencies for Middleware. // Params defines dependencies for Middleware.
type Params struct { type Params struct {
fx.In fx.In
Logger *logger.Logger Logger *logger.Logger
Config *config.Config Config *config.Config
} }
@@ -49,6 +50,7 @@ func ipFromHostPort(hp string) string {
if err != nil { if err != nil {
return "" return ""
} }
if len(h) > 0 && h[0] == '[' { if len(h) > 0 && h[0] == '[' {
return h[1 : len(h)-1] return h[1 : len(h)-1]
} }
@@ -58,6 +60,7 @@ func ipFromHostPort(hp string) string {
type loggingResponseWriter struct { type loggingResponseWriter struct {
http.ResponseWriter http.ResponseWriter
statusCode int statusCode int
bytesWritten int64 bytesWritten int64
} }
@@ -85,6 +88,7 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
start := time.Now() start := time.Now()
lrw := newLoggingResponseWriter(w) lrw := newLoggingResponseWriter(w)
ctx := r.Context() ctx := r.Context()
defer func() { defer func() {
latency := time.Since(start) latency := time.Since(start)
reqID, _ := ctx.Value(middleware.RequestIDKey).(string) reqID, _ := ctx.Value(middleware.RequestIDKey).(string)

View File

@@ -10,6 +10,8 @@ import (
) )
func TestSecurityHeaders(t *testing.T) { func TestSecurityHeaders(t *testing.T) {
t.Parallel()
// Create middleware instance // Create middleware instance
cfg := &config.Config{} cfg := &config.Config{}
mw := &Middleware{ mw := &Middleware{
@@ -26,7 +28,7 @@ func TestSecurityHeaders(t *testing.T) {
handler := mw.SecurityHeaders()(testHandler) handler := mw.SecurityHeaders()(testHandler)
// Make a test request // Make a test request
req := httptest.NewRequest(http.MethodGet, "/test", nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)
@@ -44,6 +46,8 @@ func TestSecurityHeaders(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.header, func(t *testing.T) { t.Run(tt.header, func(t *testing.T) {
t.Parallel()
got := rec.Header().Get(tt.header) got := rec.Header().Get(tt.header)
if got != tt.want { if got != tt.want {
t.Errorf("%s = %q, want %q", tt.header, got, tt.want) t.Errorf("%s = %q, want %q", tt.header, got, tt.want)
@@ -53,6 +57,8 @@ func TestSecurityHeaders(t *testing.T) {
} }
func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) { func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) {
t.Parallel()
cfg := &config.Config{} cfg := &config.Config{}
mw := &Middleware{ mw := &Middleware{
log: slog.Default(), log: slog.Default(),
@@ -68,7 +74,7 @@ func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) {
handler := mw.SecurityHeaders()(testHandler) handler := mw.SecurityHeaders()(testHandler)
req := httptest.NewRequest(http.MethodGet, "/test", nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)

View File

@@ -34,7 +34,8 @@ func DeriveKey(masterKey []byte, salt string) ([KeySize]byte, error) {
hkdfReader := hkdf.New(sha256.New, masterKey, []byte(salt), nil) hkdfReader := hkdf.New(sha256.New, masterKey, []byte(salt), nil)
if _, err := io.ReadFull(hkdfReader, key[:]); err != nil { _, err := io.ReadFull(hkdfReader, key[:])
if err != nil {
return key, ErrKeyDerivation return key, ErrKeyDerivation
} }
@@ -46,7 +47,9 @@ func DeriveKey(masterKey []byte, salt string) ([KeySize]byte, error) {
func Encrypt(key [KeySize]byte, plaintext []byte) (string, error) { func Encrypt(key [KeySize]byte, plaintext []byte) (string, error) {
// Generate random nonce // Generate random nonce
var nonce [NonceSize]byte var nonce [NonceSize]byte
if _, err := rand.Read(nonce[:]); err != nil {
_, err := rand.Read(nonce[:])
if err != nil {
return "", err return "", err
} }

View File

@@ -1,20 +1,25 @@
package seal package seal_test
import ( import (
"bytes" "bytes"
"errors"
"testing" "testing"
"sneak.berlin/go/pixa/internal/seal"
) )
func TestDeriveKey_Consistent(t *testing.T) { func TestDeriveKey_Consistent(t *testing.T) {
t.Parallel()
masterKey := []byte("test-master-key-12345") masterKey := []byte("test-master-key-12345")
salt := "test-salt-v1" salt := "test-salt-v1"
key1, err := DeriveKey(masterKey, salt) key1, err := seal.DeriveKey(masterKey, salt)
if err != nil { if err != nil {
t.Fatalf("DeriveKey() error = %v", err) t.Fatalf("DeriveKey() error = %v", err)
} }
key2, err := DeriveKey(masterKey, salt) key2, err := seal.DeriveKey(masterKey, salt)
if err != nil { if err != nil {
t.Fatalf("DeriveKey() error = %v", err) t.Fatalf("DeriveKey() error = %v", err)
} }
@@ -25,14 +30,16 @@ func TestDeriveKey_Consistent(t *testing.T) {
} }
func TestDeriveKey_DifferentSalts(t *testing.T) { func TestDeriveKey_DifferentSalts(t *testing.T) {
t.Parallel()
masterKey := []byte("test-master-key-12345") masterKey := []byte("test-master-key-12345")
key1, err := DeriveKey(masterKey, "salt-1") key1, err := seal.DeriveKey(masterKey, "salt-1")
if err != nil { if err != nil {
t.Fatalf("DeriveKey() error = %v", err) t.Fatalf("DeriveKey() error = %v", err)
} }
key2, err := DeriveKey(masterKey, "salt-2") key2, err := seal.DeriveKey(masterKey, "salt-2")
if err != nil { if err != nil {
t.Fatalf("DeriveKey() error = %v", err) t.Fatalf("DeriveKey() error = %v", err)
} }
@@ -43,14 +50,16 @@ func TestDeriveKey_DifferentSalts(t *testing.T) {
} }
func TestDeriveKey_DifferentMasterKeys(t *testing.T) { func TestDeriveKey_DifferentMasterKeys(t *testing.T) {
t.Parallel()
salt := "test-salt" salt := "test-salt"
key1, err := DeriveKey([]byte("master-key-1"), salt) key1, err := seal.DeriveKey([]byte("master-key-1"), salt)
if err != nil { if err != nil {
t.Fatalf("DeriveKey() error = %v", err) t.Fatalf("DeriveKey() error = %v", err)
} }
key2, err := DeriveKey([]byte("master-key-2"), salt) key2, err := seal.DeriveKey([]byte("master-key-2"), salt)
if err != nil { if err != nil {
t.Fatalf("DeriveKey() error = %v", err) t.Fatalf("DeriveKey() error = %v", err)
} }
@@ -61,19 +70,21 @@ func TestDeriveKey_DifferentMasterKeys(t *testing.T) {
} }
func TestEncryptDecrypt_RoundTrip(t *testing.T) { func TestEncryptDecrypt_RoundTrip(t *testing.T) {
key, err := DeriveKey([]byte("test-key"), "test-salt") t.Parallel()
key, err := seal.DeriveKey([]byte("test-key"), "test-salt")
if err != nil { if err != nil {
t.Fatalf("DeriveKey() error = %v", err) t.Fatalf("DeriveKey() error = %v", err)
} }
plaintext := []byte("hello, world! this is a test message.") plaintext := []byte("hello, world! this is a test message.")
ciphertext, err := Encrypt(key, plaintext) ciphertext, err := seal.Encrypt(key, plaintext)
if err != nil { if err != nil {
t.Fatalf("Encrypt() error = %v", err) t.Fatalf("Encrypt() error = %v", err)
} }
decrypted, err := Decrypt(key, ciphertext) decrypted, err := seal.Decrypt(key, ciphertext)
if err != nil { if err != nil {
t.Fatalf("Decrypt() error = %v", err) t.Fatalf("Decrypt() error = %v", err)
} }
@@ -84,15 +95,17 @@ func TestEncryptDecrypt_RoundTrip(t *testing.T) {
} }
func TestEncryptDecrypt_EmptyPlaintext(t *testing.T) { func TestEncryptDecrypt_EmptyPlaintext(t *testing.T) {
key, _ := DeriveKey([]byte("test-key"), "test-salt") t.Parallel()
key, _ := seal.DeriveKey([]byte("test-key"), "test-salt")
plaintext := []byte{} plaintext := []byte{}
ciphertext, err := Encrypt(key, plaintext) ciphertext, err := seal.Encrypt(key, plaintext)
if err != nil { if err != nil {
t.Fatalf("Encrypt() error = %v", err) t.Fatalf("Encrypt() error = %v", err)
} }
decrypted, err := Decrypt(key, ciphertext) decrypted, err := seal.Decrypt(key, ciphertext)
if err != nil { if err != nil {
t.Fatalf("Decrypt() error = %v", err) t.Fatalf("Decrypt() error = %v", err)
} }
@@ -103,31 +116,35 @@ func TestEncryptDecrypt_EmptyPlaintext(t *testing.T) {
} }
func TestDecrypt_WrongKey(t *testing.T) { func TestDecrypt_WrongKey(t *testing.T) {
key1, _ := DeriveKey([]byte("key-1"), "salt") t.Parallel()
key2, _ := DeriveKey([]byte("key-2"), "salt")
key1, _ := seal.DeriveKey([]byte("key-1"), "salt")
key2, _ := seal.DeriveKey([]byte("key-2"), "salt")
plaintext := []byte("secret message") plaintext := []byte("secret message")
ciphertext, err := Encrypt(key1, plaintext) ciphertext, err := seal.Encrypt(key1, plaintext)
if err != nil { if err != nil {
t.Fatalf("Encrypt() error = %v", err) t.Fatalf("Encrypt() error = %v", err)
} }
_, err = Decrypt(key2, ciphertext) _, err = seal.Decrypt(key2, ciphertext)
if err == nil { if err == nil {
t.Error("Decrypt() should fail with wrong key") t.Error("Decrypt() should fail with wrong key")
} }
if err != ErrDecryptionFailed { if !errors.Is(err, seal.ErrDecryptionFailed) {
t.Errorf("Decrypt() error = %v, want %v", err, ErrDecryptionFailed) t.Errorf("Decrypt() error = %v, want %v", err, seal.ErrDecryptionFailed)
} }
} }
func TestDecrypt_TamperedCiphertext(t *testing.T) { func TestDecrypt_TamperedCiphertext(t *testing.T) {
key, _ := DeriveKey([]byte("test-key"), "test-salt") t.Parallel()
key, _ := seal.DeriveKey([]byte("test-key"), "test-salt")
plaintext := []byte("secret message") plaintext := []byte("secret message")
ciphertext, err := Encrypt(key, plaintext) ciphertext, err := seal.Encrypt(key, plaintext)
if err != nil { if err != nil {
t.Fatalf("Encrypt() error = %v", err) t.Fatalf("Encrypt() error = %v", err)
} }
@@ -138,45 +155,51 @@ func TestDecrypt_TamperedCiphertext(t *testing.T) {
tampered[10] ^= 0x01 tampered[10] ^= 0x01
} }
_, err = Decrypt(key, string(tampered)) _, err = seal.Decrypt(key, string(tampered))
if err == nil { if err == nil {
t.Error("Decrypt() should fail with tampered ciphertext") t.Error("Decrypt() should fail with tampered ciphertext")
} }
} }
func TestDecrypt_InvalidBase64(t *testing.T) { func TestDecrypt_InvalidBase64(t *testing.T) {
key, _ := DeriveKey([]byte("test-key"), "test-salt") t.Parallel()
_, err := Decrypt(key, "not-valid-base64!!!") key, _ := seal.DeriveKey([]byte("test-key"), "test-salt")
_, err := seal.Decrypt(key, "not-valid-base64!!!")
if err == nil { if err == nil {
t.Error("Decrypt() should fail with invalid base64") t.Error("Decrypt() should fail with invalid base64")
} }
if err != ErrInvalidPayload { if !errors.Is(err, seal.ErrInvalidPayload) {
t.Errorf("Decrypt() error = %v, want %v", err, ErrInvalidPayload) t.Errorf("Decrypt() error = %v, want %v", err, seal.ErrInvalidPayload)
} }
} }
func TestDecrypt_TooShort(t *testing.T) { func TestDecrypt_TooShort(t *testing.T) {
key, _ := DeriveKey([]byte("test-key"), "test-salt") t.Parallel()
key, _ := seal.DeriveKey([]byte("test-key"), "test-salt")
// Create a base64 string that's too short to contain nonce + auth tag // Create a base64 string that's too short to contain nonce + auth tag
_, err := Decrypt(key, "dG9vLXNob3J0") _, err := seal.Decrypt(key, "dG9vLXNob3J0")
if err == nil { if err == nil {
t.Error("Decrypt() should fail with too-short ciphertext") t.Error("Decrypt() should fail with too-short ciphertext")
} }
if err != ErrInvalidPayload { if !errors.Is(err, seal.ErrInvalidPayload) {
t.Errorf("Decrypt() error = %v, want %v", err, ErrInvalidPayload) t.Errorf("Decrypt() error = %v, want %v", err, seal.ErrInvalidPayload)
} }
} }
func TestEncrypt_ProducesDifferentCiphertexts(t *testing.T) { func TestEncrypt_ProducesDifferentCiphertexts(t *testing.T) {
key, _ := DeriveKey([]byte("test-key"), "test-salt") t.Parallel()
key, _ := seal.DeriveKey([]byte("test-key"), "test-salt")
plaintext := []byte("same message") plaintext := []byte("same message")
ciphertext1, _ := Encrypt(key, plaintext) ciphertext1, _ := seal.Encrypt(key, plaintext)
ciphertext2, _ := Encrypt(key, plaintext) ciphertext2, _ := seal.Encrypt(key, plaintext)
if ciphertext1 == ciphertext2 { if ciphertext1 == ciphertext2 {
t.Error("Encrypt() should produce different ciphertexts due to random nonce") t.Error("Encrypt() should produce different ciphertexts due to random nonce")

View File

@@ -1,6 +1,7 @@
package server package server
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"time" "time"
@@ -26,8 +27,11 @@ func (s *Server) serveUntilShutdown() {
s.SetupRoutes() s.SetupRoutes()
s.log.Info("http begin listen", "listenaddr", listenAddr) s.log.Info("http begin listen", "listenaddr", listenAddr)
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
err := s.httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Error("listen error", "error", err) s.log.Error("listen error", "error", err)
if s.cancelFunc != nil { if s.cancelFunc != nil {
s.cancelFunc() s.cancelFunc()
} }

View File

@@ -56,7 +56,8 @@ func (s *Server) SetupRoutes() {
s.router.Head("/v1/image/*", s.h.HandleImage()) s.router.Head("/v1/image/*", s.h.HandleImage())
// Encrypted image URL route // Encrypted image URL route
// The trailing filename (e.g., /img.jpg) is ignored but helps browsers with content type // The trailing filename (e.g., /img.jpg) is ignored but helps
// browsers with content type
s.router.Get("/v1/e/{token}/*", s.h.HandleImageEnc()) s.router.Get("/v1/e/{token}/*", s.h.HandleImageEnc())
// Metrics endpoint with auth // Metrics endpoint with auth

View File

@@ -30,6 +30,7 @@ const (
// Params defines dependencies for Server. // Params defines dependencies for Server.
type Params struct { type Params struct {
fx.In fx.In
Logger *logger.Logger Logger *logger.Logger
Globals *globals.Globals Globals *globals.Globals
Config *config.Config Config *config.Config
@@ -47,7 +48,6 @@ type Server struct {
startupTime time.Time startupTime time.Time
exitCode int exitCode int
sentryEnabled bool sentryEnabled bool
ctx context.Context
cancelFunc context.CancelFunc cancelFunc context.CancelFunc
httpServer *http.Server httpServer *http.Server
router *chi.Mux router *chi.Mux
@@ -64,9 +64,9 @@ func New(lc fx.Lifecycle, params Params) (*Server, error) {
} }
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(_ context.Context) error { OnStart: func(ctx context.Context) error {
s.startupTime = time.Now() s.startupTime = time.Now()
go s.Run() go s.Run(context.WithoutCancel(ctx))
return nil return nil
}, },
@@ -83,9 +83,14 @@ func New(lc fx.Lifecycle, params Params) (*Server, error) {
} }
// Run starts the server. // Run starts the server.
func (s *Server) Run() { func (s *Server) Run(ctx context.Context) {
s.enableSentry() s.enableSentry()
s.serve() s.serve(ctx)
}
// MaintenanceMode returns whether maintenance mode is enabled.
func (s *Server) MaintenanceMode() bool {
return s.config.MaintenanceMode
} }
func (s *Server) enableSentry() { func (s *Server) enableSentry() {
@@ -103,19 +108,24 @@ func (s *Server) enableSentry() {
s.log.Error("sentry init failure", "error", err) s.log.Error("sentry init failure", "error", err)
os.Exit(1) os.Exit(1)
} }
s.log.Info("sentry error reporting activated") s.log.Info("sentry error reporting activated")
s.sentryEnabled = true s.sentryEnabled = true
} }
func (s *Server) serve() int { func (s *Server) serve(ctx context.Context) int {
s.ctx, s.cancelFunc = context.WithCancel(context.Background()) ctx, cancelFunc := context.WithCancel(ctx)
s.cancelFunc = cancelFunc
go func() { go func() {
c := make(chan os.Signal, 1) c := make(chan os.Signal, 1)
signal.Ignore(syscall.SIGPIPE) signal.Ignore(syscall.SIGPIPE)
signal.Notify(c, os.Interrupt, syscall.SIGTERM) signal.Notify(c, os.Interrupt, syscall.SIGTERM)
sig := <-c sig := <-c
s.log.Info("signal received", "signal", sig) s.log.Info("signal received", "signal", sig)
if s.cancelFunc != nil { if s.cancelFunc != nil {
s.cancelFunc() s.cancelFunc()
} }
@@ -123,19 +133,22 @@ func (s *Server) serve() int {
go s.serveUntilShutdown() go s.serveUntilShutdown()
<-s.ctx.Done() <-ctx.Done()
s.cleanShutdown() s.cleanShutdown(ctx)
return s.exitCode return s.exitCode
} }
func (s *Server) cleanShutdown() { func (s *Server) cleanShutdown(ctx context.Context) {
s.exitCode = 0 s.exitCode = 0
ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), ShutdownTimeout)
ctxShutdown, shutdownCancel := context.WithTimeout(
context.WithoutCancel(ctx), ShutdownTimeout)
defer shutdownCancel() defer shutdownCancel()
if s.httpServer != nil { if s.httpServer != nil {
if err := s.httpServer.Shutdown(ctxShutdown); err != nil { err := s.httpServer.Shutdown(ctxShutdown)
if err != nil {
s.log.Error("server clean shutdown failed", "error", err) s.log.Error("server clean shutdown failed", "error", err)
} }
} }
@@ -144,8 +157,3 @@ func (s *Server) cleanShutdown() {
sentry.Flush(SentryFlushTimeout) sentry.Flush(SentryFlushTimeout)
} }
} }
// MaintenanceMode returns whether maintenance mode is enabled.
func (s *Server) MaintenanceMode() bool {
return s.config.MaintenanceMode
}

View File

@@ -107,7 +107,9 @@ func (m *Manager) ValidateSession(r *http.Request) (*Data, error) {
} }
var data Data var data Data
if err := m.sc.Decode(CookieName, cookie.Value, &data); err != nil {
err = m.sc.Decode(CookieName, cookie.Value, &data)
if err != nil {
return nil, ErrInvalidSession return nil, ErrInvalidSession
} }

View File

@@ -1,9 +1,11 @@
package session package session_test
import ( import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"sneak.berlin/go/pixa/internal/session"
) )
// TestSessionCookieAttributesAlwaysSecure verifies that every cookie // TestSessionCookieAttributesAlwaysSecure verifies that every cookie
@@ -16,7 +18,9 @@ import (
// This covers both cookie-writing paths: CreateSession (the login // This covers both cookie-writing paths: CreateSession (the login
// set-cookie path) and ClearSession (the logout delete-cookie path). // set-cookie path) and ClearSession (the logout delete-cookie path).
func TestSessionCookieAttributesAlwaysSecure(t *testing.T) { func TestSessionCookieAttributesAlwaysSecure(t *testing.T) {
mgr, err := NewManager("test-signing-key-12345") t.Parallel()
mgr, err := session.NewManager("test-signing-key-12345")
if err != nil { if err != nil {
t.Fatalf("NewManager() error = %v", err) t.Fatalf("NewManager() error = %v", err)
} }
@@ -29,7 +33,9 @@ func TestSessionCookieAttributesAlwaysSecure(t *testing.T) {
name: "CreateSession", name: "CreateSession",
setCookie: func(t *testing.T, w http.ResponseWriter) { setCookie: func(t *testing.T, w http.ResponseWriter) {
t.Helper() t.Helper()
if err := mgr.CreateSession(w); err != nil {
err := mgr.CreateSession(w)
if err != nil {
t.Fatalf("CreateSession() error = %v", err) t.Fatalf("CreateSession() error = %v", err)
} }
}, },
@@ -45,12 +51,15 @@ func TestSessionCookieAttributesAlwaysSecure(t *testing.T) {
for _, writePath := range writePaths { for _, writePath := range writePaths {
t.Run(writePath.name, func(t *testing.T) { t.Run(writePath.name, func(t *testing.T) {
t.Parallel()
w := httptest.NewRecorder() w := httptest.NewRecorder()
writePath.setCookie(t, w) writePath.setCookie(t, w)
var sessionCookie *http.Cookie var sessionCookie *http.Cookie
for _, c := range w.Result().Cookies() { for _, c := range w.Result().Cookies() {
if c.Name == CookieName { if c.Name == session.CookieName {
sessionCookie = c sessionCookie = c
break break
@@ -58,7 +67,7 @@ func TestSessionCookieAttributesAlwaysSecure(t *testing.T) {
} }
if sessionCookie == nil { if sessionCookie == nil {
t.Fatalf("no cookie named %q was set", CookieName) t.Fatalf("no cookie named %q was set", session.CookieName)
} }
t.Logf("cookie attributes: HttpOnly=%v Secure=%v SameSite=%v", t.Logf("cookie attributes: HttpOnly=%v Secure=%v SameSite=%v",

View File

@@ -1,45 +1,55 @@
package session package session_test
import ( import (
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time" "time"
"sneak.berlin/go/pixa/internal/session"
) )
func TestManager_CreateAndValidate(t *testing.T) { func TestManager_CreateAndValidate(t *testing.T) {
mgr, err := NewManager("test-signing-key-12345") t.Parallel()
mgr, err := session.NewManager("test-signing-key-12345")
if err != nil { if err != nil {
t.Fatalf("NewManager() error = %v", err) t.Fatalf("NewManager() error = %v", err)
} }
// Create a session // Create a session
w := httptest.NewRecorder() w := httptest.NewRecorder()
if err := mgr.CreateSession(w); err != nil {
err = mgr.CreateSession(w)
if err != nil {
t.Fatalf("CreateSession() error = %v", err) t.Fatalf("CreateSession() error = %v", err)
} }
// Extract the cookie from response // Extract the cookie from response
resp := w.Result() resp := w.Result()
cookies := resp.Cookies() cookies := resp.Cookies()
if len(cookies) == 0 { if len(cookies) == 0 {
t.Fatal("CreateSession() did not set a cookie") t.Fatal("CreateSession() did not set a cookie")
} }
var sessionCookie *http.Cookie var sessionCookie *http.Cookie
for _, c := range cookies { for _, c := range cookies {
if c.Name == CookieName { if c.Name == session.CookieName {
sessionCookie = c sessionCookie = c
break break
} }
} }
if sessionCookie == nil { if sessionCookie == nil {
t.Fatalf("CreateSession() did not set cookie named %q", CookieName) t.Fatalf("CreateSession() did not set cookie named %q", session.CookieName)
} }
// Validate the session // Validate the session
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.AddCookie(sessionCookie) req.AddCookie(sessionCookie)
data, err := mgr.ValidateSession(req) data, err := mgr.ValidateSession(req)
@@ -57,27 +67,34 @@ func TestManager_CreateAndValidate(t *testing.T) {
} }
func TestManager_ValidateSession_NoCookie(t *testing.T) { func TestManager_ValidateSession_NoCookie(t *testing.T) {
mgr, _ := NewManager("test-signing-key-12345") t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil) mgr, _ := session.NewManager("test-signing-key-12345")
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
_, err := mgr.ValidateSession(req) _, err := mgr.ValidateSession(req)
if err == nil { if err == nil {
t.Error("ValidateSession() should fail with no cookie") t.Error("ValidateSession() should fail with no cookie")
} }
if err != ErrNoSession { if !errors.Is(err, session.ErrNoSession) {
t.Errorf("ValidateSession() error = %v, want %v", err, ErrNoSession) t.Errorf("ValidateSession() error = %v, want %v", err, session.ErrNoSession)
} }
} }
func TestManager_ValidateSession_TamperedCookie(t *testing.T) { func TestManager_ValidateSession_TamperedCookie(t *testing.T) {
mgr, _ := NewManager("test-signing-key-12345") t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil) mgr, _ := session.NewManager("test-signing-key-12345")
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{ req.AddCookie(&http.Cookie{
Name: CookieName, Name: session.CookieName,
Value: "tampered-invalid-cookie-value", Value: "tampered-invalid-cookie-value",
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}) })
_, err := mgr.ValidateSession(req) _, err := mgr.ValidateSession(req)
@@ -85,30 +102,35 @@ func TestManager_ValidateSession_TamperedCookie(t *testing.T) {
t.Error("ValidateSession() should fail with tampered cookie") t.Error("ValidateSession() should fail with tampered cookie")
} }
if err != ErrInvalidSession { if !errors.Is(err, session.ErrInvalidSession) {
t.Errorf("ValidateSession() error = %v, want %v", err, ErrInvalidSession) t.Errorf("ValidateSession() error = %v, want %v", err, session.ErrInvalidSession)
} }
} }
func TestManager_ValidateSession_WrongKey(t *testing.T) { func TestManager_ValidateSession_WrongKey(t *testing.T) {
mgr1, _ := NewManager("signing-key-1") t.Parallel()
mgr2, _ := NewManager("signing-key-2")
mgr1, _ := session.NewManager("signing-key-1")
mgr2, _ := session.NewManager("signing-key-2")
// Create session with mgr1 // Create session with mgr1
w := httptest.NewRecorder() w := httptest.NewRecorder()
_ = mgr1.CreateSession(w) _ = mgr1.CreateSession(w)
resp := w.Result() resp := w.Result()
var sessionCookie *http.Cookie var sessionCookie *http.Cookie
for _, c := range resp.Cookies() { for _, c := range resp.Cookies() {
if c.Name == CookieName { if c.Name == session.CookieName {
sessionCookie = c sessionCookie = c
break break
} }
} }
// Try to validate with mgr2 (different key) // Try to validate with mgr2 (different key)
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.AddCookie(sessionCookie) req.AddCookie(sessionCookie)
_, err := mgr2.ValidateSession(req) _, err := mgr2.ValidateSession(req)
@@ -118,7 +140,9 @@ func TestManager_ValidateSession_WrongKey(t *testing.T) {
} }
func TestManager_ClearSession(t *testing.T) { func TestManager_ClearSession(t *testing.T) {
mgr, _ := NewManager("test-signing-key-12345") t.Parallel()
mgr, _ := session.NewManager("test-signing-key-12345")
w := httptest.NewRecorder() w := httptest.NewRecorder()
mgr.ClearSession(w) mgr.ClearSession(w)
@@ -127,9 +151,11 @@ func TestManager_ClearSession(t *testing.T) {
cookies := resp.Cookies() cookies := resp.Cookies()
var sessionCookie *http.Cookie var sessionCookie *http.Cookie
for _, c := range cookies { for _, c := range cookies {
if c.Name == CookieName { if c.Name == session.CookieName {
sessionCookie = c sessionCookie = c
break break
} }
} }
@@ -144,10 +170,12 @@ func TestManager_ClearSession(t *testing.T) {
} }
func TestManager_IsAuthenticated(t *testing.T) { func TestManager_IsAuthenticated(t *testing.T) {
mgr, _ := NewManager("test-signing-key-12345") t.Parallel()
mgr, _ := session.NewManager("test-signing-key-12345")
// No session - should return false // No session - should return false
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
if mgr.IsAuthenticated(req) { if mgr.IsAuthenticated(req) {
t.Error("IsAuthenticated() should return false with no session") t.Error("IsAuthenticated() should return false with no session")
} }
@@ -157,16 +185,19 @@ func TestManager_IsAuthenticated(t *testing.T) {
_ = mgr.CreateSession(w) _ = mgr.CreateSession(w)
resp := w.Result() resp := w.Result()
var sessionCookie *http.Cookie var sessionCookie *http.Cookie
for _, c := range resp.Cookies() { for _, c := range resp.Cookies() {
if c.Name == CookieName { if c.Name == session.CookieName {
sessionCookie = c sessionCookie = c
break break
} }
} }
// With valid session - should return true // With valid session - should return true
req = httptest.NewRequest(http.MethodGet, "/", nil) req = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.AddCookie(sessionCookie) req.AddCookie(sessionCookie)
if !mgr.IsAuthenticated(req) { if !mgr.IsAuthenticated(req) {
@@ -175,16 +206,21 @@ func TestManager_IsAuthenticated(t *testing.T) {
} }
func TestManager_CookieAttributes(t *testing.T) { func TestManager_CookieAttributes(t *testing.T) {
mgr, _ := NewManager("test-key") t.Parallel()
mgr, _ := session.NewManager("test-key")
w := httptest.NewRecorder() w := httptest.NewRecorder()
_ = mgr.CreateSession(w) _ = mgr.CreateSession(w)
resp := w.Result() resp := w.Result()
var sessionCookie *http.Cookie var sessionCookie *http.Cookie
for _, c := range resp.Cookies() { for _, c := range resp.Cookies() {
if c.Name == CookieName { if c.Name == session.CookieName {
sessionCookie = c sessionCookie = c
break break
} }
} }
@@ -198,6 +234,7 @@ func TestManager_CookieAttributes(t *testing.T) {
} }
if sessionCookie.SameSite != http.SameSiteStrictMode { if sessionCookie.SameSite != http.SameSiteStrictMode {
t.Errorf("Cookie SameSite = %v, want %v", sessionCookie.SameSite, http.SameSiteStrictMode) t.Errorf("Cookie SameSite = %v, want %v",
sessionCookie.SameSite, http.SameSiteStrictMode)
} }
} }

View File

@@ -1,8 +1,10 @@
package signature package signature_test
import ( import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/pixa/internal/signature"
) )
// goldenExpiresUnix is the fixed expiration timestamp used by all golden // goldenExpiresUnix is the fixed expiration timestamp used by all golden
@@ -12,10 +14,74 @@ const goldenExpiresUnix int64 = 1704067200
// goldenSigningKey is the fixed signing key used by all golden vectors. // goldenSigningKey is the fixed signing key used by all golden vectors.
const goldenSigningKey = "golden-test-key" const goldenSigningKey = "golden-test-key"
type goldenVector struct {
name string
req signature.Request
// wantSignature is the exact base64url (RFC 4648 URL-safe,
// padded) HMAC-SHA256 signature for the request with Expires
// set to goldenExpiresUnix.
wantSignature string
// wantSignedPath is the exact path returned by
// GenerateSignedURL for the request. The signature and
// expiration are returned separately by GenerateSignedURL and
// are not embedded in the path.
wantSignedPath string
}
// goldenVectors returns the known-answer vectors. The expected values
// were computed once and are hardcoded here.
func goldenVectors() []goldenVector {
return []goldenVector{
{
name: "resized without query",
req: signature.Request{
SourceHost: testHost,
SourcePath: testPath,
SourceQuery: "",
Width: 800,
Height: 600,
Format: testFormatWebP,
},
// Signed data: "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200"
wantSignature: "x5PfPp8QSDo0cJT96od-AEgrQyOVLfqifH5sst61_-w=",
wantSignedPath: testSignedPath,
},
{
name: "resized with query string",
req: signature.Request{
SourceHost: testHost,
SourcePath: testPath,
SourceQuery: "token=abc&v=2",
Width: 800,
Height: 600,
Format: testFormatWebP,
},
// Signed data:
// "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200"
wantSignature: "394_Vf9TdQFkpQ3XKFDQSyxgqKq8N7mApf2S4QaHqyo=",
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg" +
"%3Ftoken=abc&v=2/800x600.webp",
},
{
name: "original size without query",
req: signature.Request{
SourceHost: testHost,
SourcePath: testPath,
SourceQuery: "",
Width: 0,
Height: 0,
Format: testFormatPNG,
},
// Signed data: "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200"
wantSignature: "7Be7oteeQwvnSPU4bchyQ4ZGYGsAGBKpeEtuQ02ox60=",
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
},
}
}
// TestSigner_GoldenVectors pins the exact HMAC-SHA256 signature output and // TestSigner_GoldenVectors pins the exact HMAC-SHA256 signature output and
// the exact generated signed URL path for fully-specified requests with a // the exact generated signed URL path for fully-specified requests with a
// hardcoded signing key. The expected values were computed once and are // hardcoded signing key.
// hardcoded here as known answers.
// //
// If any of these assertions fail, the signed byte format // If any of these assertions fail, the signed byte format
// ("host:path:query:width:height:format:expiration"), the base64url // ("host:path:query:width:height:format:expiration"), the base64url
@@ -24,67 +90,14 @@ const goldenSigningKey = "golden-test-key"
// deliberately: update these constants only as part of an intentional, // deliberately: update these constants only as part of an intentional,
// documented signature format migration. // documented signature format migration.
func TestSigner_GoldenVectors(t *testing.T) { func TestSigner_GoldenVectors(t *testing.T) {
signer := New(goldenSigningKey) t.Parallel()
vectors := []struct { signer := signature.New(goldenSigningKey)
name string
req Request
// wantSignature is the exact base64url (RFC 4648 URL-safe,
// padded) HMAC-SHA256 signature for the request with Expires
// set to goldenExpiresUnix.
wantSignature string
// wantSignedPath is the exact path returned by
// GenerateSignedURL for the request. The signature and
// expiration are returned separately by GenerateSignedURL and
// are not embedded in the path.
wantSignedPath string
}{
{
name: "resized without query",
req: Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
SourceQuery: "",
Width: 800,
Height: 600,
Format: "webp",
},
// Signed data: "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200"
wantSignature: "x5PfPp8QSDo0cJT96od-AEgrQyOVLfqifH5sst61_-w=",
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp",
},
{
name: "resized with query string",
req: Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
SourceQuery: "token=abc&v=2",
Width: 800,
Height: 600,
Format: "webp",
},
// Signed data: "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200"
wantSignature: "394_Vf9TdQFkpQ3XKFDQSyxgqKq8N7mApf2S4QaHqyo=",
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg%3Ftoken=abc&v=2/800x600.webp",
},
{
name: "original size without query",
req: Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
SourceQuery: "",
Width: 0,
Height: 0,
Format: "png",
},
// Signed data: "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200"
wantSignature: "7Be7oteeQwvnSPU4bchyQ4ZGYGsAGBKpeEtuQ02ox60=",
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
},
}
for _, tt := range vectors { for _, tt := range goldenVectors() {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel()
signReq := tt.req signReq := tt.req
signReq.Expires = time.Unix(goldenExpiresUnix, 0) signReq.Expires = time.Unix(goldenExpiresUnix, 0)
@@ -95,9 +108,10 @@ func TestSigner_GoldenVectors(t *testing.T) {
} }
urlReq := tt.req urlReq := tt.req
gotPath, _, _ := signer.GenerateSignedURL(&urlReq, time.Hour) gotPath, _, _ := signer.GenerateSignedURL(&urlReq, time.Hour)
if gotPath != tt.wantSignedPath { if gotPath != tt.wantSignedPath {
t.Errorf("GenerateSignedURL() path = %q, want %q (signed URL layout changed?)", t.Errorf("GenerateSignedURL() path = %q, want %q (layout changed?)",
gotPath, tt.wantSignedPath) gotPath, tt.wantSignedPath)
} }
}) })

View File

@@ -93,31 +93,17 @@ func (s *Signer) Verify(req *Request) error {
return nil return nil
} }
// buildSignatureData creates the string to be signed.
// Format: "host:path:query:width:height:format:expiration"
// All components are used verbatim (exact match). No normalization,
// suffix matching, or wildcard expansion is performed.
func (s *Signer) buildSignatureData(req *Request) string {
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d",
req.SourceHost,
req.SourcePath,
req.SourceQuery,
req.Width,
req.Height,
req.Format,
req.Expires.Unix(),
)
}
// GenerateSignedURL creates a complete URL with signature and expiration. // GenerateSignedURL creates a complete URL with signature and expiration.
// Returns the path portion that should be appended to the base URL. // Returns the path portion that should be appended to the base URL.
func (s *Signer) GenerateSignedURL(req *Request, ttl time.Duration) (path string, sig string, exp int64) { func (s *Signer) GenerateSignedURL(
req *Request, ttl time.Duration,
) (string, string, int64) {
// Set expiration // Set expiration
req.Expires = time.Now().Add(ttl) req.Expires = time.Now().Add(ttl)
exp = req.Expires.Unix() exp := req.Expires.Unix()
// Generate signature // Generate signature
sig = s.Sign(req) sig := s.Sign(req)
req.Signature = sig req.Signature = sig
// Build the size component // Build the size component
@@ -134,6 +120,7 @@ func (s *Signer) GenerateSignedURL(req *Request, ttl time.Duration) (path string
// it from the last-slash split. The "?" inside a path segment is // it from the last-slash split. The "?" inside a path segment is
// percent-encoded by clients but chi delivers it decoded, which is // percent-encoded by clients but chi delivers it decoded, which is
// exactly what the URL parser expects. // exactly what the URL parser expects.
var path string
if req.SourceQuery != "" { if req.SourceQuery != "" {
path = fmt.Sprintf("/v1/image/%s%s%%3F%s/%s.%s", path = fmt.Sprintf("/v1/image/%s%s%%3F%s/%s.%s",
req.SourceHost, req.SourceHost,
@@ -154,12 +141,26 @@ func (s *Signer) GenerateSignedURL(req *Request, ttl time.Duration) (path string
return path, sig, exp return path, sig, exp
} }
// ParseParams extracts signature and expiration from query parameters. // buildSignatureData creates the string to be signed.
func ParseParams(sig, expStr string) (parsed string, expires time.Time, err error) { // Format: "host:path:query:width:height:format:expiration"
parsed = sig // All components are used verbatim (exact match). No normalization,
// suffix matching, or wildcard expansion is performed.
func (s *Signer) buildSignatureData(req *Request) string {
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d",
req.SourceHost,
req.SourcePath,
req.SourceQuery,
req.Width,
req.Height,
req.Format,
req.Expires.Unix(),
)
}
// ParseParams extracts signature and expiration from query parameters.
func ParseParams(sig, expStr string) (string, time.Time, error) {
if expStr == "" { if expStr == "" {
return parsed, time.Time{}, nil return sig, time.Time{}, nil
} }
expUnix, err := strconv.ParseInt(expStr, 10, 64) expUnix, err := strconv.ParseInt(expStr, 10, 64)
@@ -167,7 +168,5 @@ func ParseParams(sig, expStr string) (parsed string, expires time.Time, err erro
return "", time.Time{}, fmt.Errorf("invalid expiration: %w", err) return "", time.Time{}, fmt.Errorf("invalid expiration: %w", err)
} }
expires = time.Unix(expUnix, 0) return sig, time.Unix(expUnix, 0), nil
return parsed, expires, nil
} }

View File

@@ -1,21 +1,36 @@
package signature package signature_test
import ( import (
"errors"
"strings" "strings"
"testing" "testing"
"time" "time"
"sneak.berlin/go/pixa/internal/signature"
)
// Shared fixture values used across the signature tests.
const (
testHost = "cdn.example.com"
testPath = "/photos/cat.jpg"
testFormatWebP = "webp"
testFormatPNG = "png"
testSignedPath = "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp"
testSig = "abc123"
) )
func TestSigner_Sign(t *testing.T) { func TestSigner_Sign(t *testing.T) {
signer := New("test-secret-key") t.Parallel()
req := &Request{ signer := signature.New("test-secret-key")
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg", req := &signature.Request{
SourceHost: testHost,
SourcePath: testPath,
SourceQuery: "", SourceQuery: "",
Width: 800, Width: 800,
Height: 600, Height: 600,
Format: "webp", Format: testFormatWebP,
Expires: time.Unix(1704067200, 0), // Fixed timestamp for reproducibility Expires: time.Unix(1704067200, 0), // Fixed timestamp for reproducibility
} }
@@ -24,7 +39,8 @@ func TestSigner_Sign(t *testing.T) {
// Same input should produce same signature // Same input should produce same signature
if sig1 != sig2 { if sig1 != sig2 {
t.Errorf("Sign() produced different signatures for same input: %q vs %q", sig1, sig2) t.Errorf("Sign() produced different signatures for same input: %q vs %q",
sig1, sig2)
} }
// Signature should be non-empty // Signature should be non-empty
@@ -33,13 +49,13 @@ func TestSigner_Sign(t *testing.T) {
} }
// Different input should produce different signature // Different input should produce different signature
req2 := &Request{ req2 := &signature.Request{
SourceHost: "cdn.example.com", SourceHost: testHost,
SourcePath: "/photos/dog.jpg", // Different path SourcePath: "/photos/dog.jpg", // Different path
SourceQuery: "", SourceQuery: "",
Width: 800, Width: 800,
Height: 600, Height: 600,
Format: "webp", Format: testFormatWebP,
Expires: time.Unix(1704067200, 0), Expires: time.Unix(1704067200, 0),
} }
@@ -49,25 +65,31 @@ func TestSigner_Sign(t *testing.T) {
} }
} }
func TestSigner_Verify(t *testing.T) { // validVerifyRequest returns a fully-populated request that verifies
signer := New("test-secret-key") // successfully once signed.
func validVerifyRequest() *signature.Request {
return &signature.Request{
SourceHost: testHost,
SourcePath: testPath,
Width: 800,
Height: 600,
Format: testFormatWebP,
Expires: time.Now().Add(1 * time.Hour),
}
}
tests := []struct { type verifyCase struct {
name string name string
setup func() *Request setup func() *signature.Request
wantErr error wantErr error
}{ }
func verifyCases(signer *signature.Signer) []verifyCase {
return []verifyCase{
{ {
name: "valid signature", name: "valid signature",
setup: func() *Request { setup: func() *signature.Request {
req := &Request{ req := validVerifyRequest()
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
Width: 800,
Height: 600,
Format: "webp",
Expires: time.Now().Add(1 * time.Hour),
}
req.Signature = signer.Sign(req) req.Signature = signer.Sign(req)
return req return req
@@ -76,74 +98,59 @@ func TestSigner_Verify(t *testing.T) {
}, },
{ {
name: "expired signature", name: "expired signature",
setup: func() *Request { setup: func() *signature.Request {
req := &Request{ req := validVerifyRequest()
SourceHost: "cdn.example.com", req.Expires = time.Now().Add(-1 * time.Hour)
SourcePath: "/photos/cat.jpg",
Width: 800,
Height: 600,
Format: "webp",
Expires: time.Now().Add(-1 * time.Hour), // Expired
}
req.Signature = signer.Sign(req) req.Signature = signer.Sign(req)
return req return req
}, },
wantErr: ErrExpired, wantErr: signature.ErrExpired,
}, },
{ {
name: "invalid signature", name: "invalid signature",
setup: func() *Request { setup: func() *signature.Request {
return &Request{ req := validVerifyRequest()
SourceHost: "cdn.example.com", req.Signature = "invalid-signature"
SourcePath: "/photos/cat.jpg",
Width: 800, return req
Height: 600,
Format: "webp",
Expires: time.Now().Add(1 * time.Hour),
Signature: "invalid-signature",
}
}, },
wantErr: ErrInvalid, wantErr: signature.ErrInvalid,
}, },
{ {
name: "missing expiration", name: "missing expiration",
setup: func() *Request { setup: func() *signature.Request {
return &Request{ req := validVerifyRequest()
SourceHost: "cdn.example.com", req.Expires = time.Time{}
SourcePath: "/photos/cat.jpg", req.Signature = "some-signature"
Width: 800,
Height: 600, return req
Format: "webp",
Signature: "some-signature",
// Expires is zero
}
}, },
wantErr: ErrMissingExpiration, wantErr: signature.ErrMissingExpiration,
}, },
{ {
name: "tampered request", name: "tampered request",
setup: func() *Request { setup: func() *signature.Request {
req := &Request{ req := validVerifyRequest()
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
Width: 800,
Height: 600,
Format: "webp",
Expires: time.Now().Add(1 * time.Hour),
}
req.Signature = signer.Sign(req) req.Signature = signer.Sign(req)
// Tamper with the request
req.SourcePath = "/photos/secret.jpg" req.SourcePath = "/photos/secret.jpg"
return req return req
}, },
wantErr: ErrInvalid, wantErr: signature.ErrInvalid,
}, },
} }
}
for _, tt := range tests { func TestSigner_Verify(t *testing.T) {
t.Parallel()
signer := signature.New("test-secret-key")
for _, tt := range verifyCases(signer) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := tt.setup() req := tt.setup()
err := signer.Verify(req) err := signer.Verify(req)
@@ -151,30 +158,102 @@ func TestSigner_Verify(t *testing.T) {
if err != nil { if err != nil {
t.Errorf("Verify() unexpected error = %v", err) t.Errorf("Verify() unexpected error = %v", err)
} }
} else {
if err != tt.wantErr { return
t.Errorf("Verify() error = %v, wantErr %v", err, tt.wantErr) }
}
if !errors.Is(err, tt.wantErr) {
t.Errorf("Verify() error = %v, wantErr %v", err, tt.wantErr)
} }
}) })
} }
} }
type tamperCase struct {
name string
tamper func(r *signature.Request)
}
// exactMatchTamperCases mutates one signed component per case; every
// mutation must cause verification to fail with ErrInvalid.
func exactMatchTamperCases() []tamperCase {
return []tamperCase{
{
name: "parent domain does not match subdomain",
tamper: func(r *signature.Request) { r.SourceHost = "example.com" },
},
{
name: "subdomain does not match parent domain",
tamper: func(r *signature.Request) { r.SourceHost = "images.cdn.example.com" },
},
{
name: "sibling subdomain does not match",
tamper: func(r *signature.Request) { r.SourceHost = "images.example.com" },
},
{
name: "host with suffix appended does not match",
tamper: func(r *signature.Request) { r.SourceHost = testHost + ".evil.com" },
},
{
name: "host with prefix does not match",
tamper: func(r *signature.Request) { r.SourceHost = "evilcdn.example.com" },
},
{
name: "different path does not match",
tamper: func(r *signature.Request) { r.SourcePath = "/photos/dog.jpg" },
},
{
name: "path suffix does not match",
tamper: func(r *signature.Request) { r.SourcePath = testPath + "/extra" },
},
{
name: "path prefix does not match",
tamper: func(r *signature.Request) { r.SourcePath = "/other" + testPath },
},
{
name: "different query does not match",
tamper: func(r *signature.Request) { r.SourceQuery = "token=xyz" },
},
{
name: "added query does not match empty query",
tamper: func(r *signature.Request) { r.SourceQuery = "extra=1" },
},
{
name: "removed query does not match",
tamper: func(r *signature.Request) { r.SourceQuery = "" },
},
{
name: "different width does not match",
tamper: func(r *signature.Request) { r.Width = 801 },
},
{
name: "different height does not match",
tamper: func(r *signature.Request) { r.Height = 601 },
},
{
name: "different format does not match",
tamper: func(r *signature.Request) { r.Format = testFormatPNG },
},
}
}
// TestSigner_Verify_ExactMatchOnly verifies that signatures enforce exact // TestSigner_Verify_ExactMatchOnly verifies that signatures enforce exact
// matching on every URL component. No suffix matching, wildcard matching, // matching on every URL component. No suffix matching, wildcard matching,
// or partial matching is supported. // or partial matching is supported.
func TestSigner_Verify_ExactMatchOnly(t *testing.T) { func TestSigner_Verify_ExactMatchOnly(t *testing.T) {
signer := New("test-secret-key") t.Parallel()
signer := signature.New("test-secret-key")
// Base request that we'll sign, then tamper with individual fields. // Base request that we'll sign, then tamper with individual fields.
baseReq := func() *Request { baseReq := func() *signature.Request {
req := &Request{ req := &signature.Request{
SourceHost: "cdn.example.com", SourceHost: testHost,
SourcePath: "/photos/cat.jpg", SourcePath: testPath,
SourceQuery: "token=abc", SourceQuery: "token=abc",
Width: 800, Width: 800,
Height: 600, Height: 600,
Format: "webp", Format: testFormatWebP,
Expires: time.Now().Add(1 * time.Hour), Expires: time.Now().Add(1 * time.Hour),
} }
req.Signature = signer.Sign(req) req.Signature = signer.Sign(req)
@@ -182,117 +261,28 @@ func TestSigner_Verify_ExactMatchOnly(t *testing.T) {
return req return req
} }
tests := []struct { for _, tt := range exactMatchTamperCases() {
name string
tamper func(req *Request)
}{
{
name: "parent domain does not match subdomain",
tamper: func(req *Request) {
// Signed for cdn.example.com, try example.com
req.SourceHost = "example.com"
},
},
{
name: "subdomain does not match parent domain",
tamper: func(req *Request) {
// Signed for cdn.example.com, try images.cdn.example.com
req.SourceHost = "images.cdn.example.com"
},
},
{
name: "sibling subdomain does not match",
tamper: func(req *Request) {
// Signed for cdn.example.com, try images.example.com
req.SourceHost = "images.example.com"
},
},
{
name: "host with suffix appended does not match",
tamper: func(req *Request) {
// Signed for cdn.example.com, try cdn.example.com.evil.com
req.SourceHost = "cdn.example.com.evil.com"
},
},
{
name: "host with prefix does not match",
tamper: func(req *Request) {
// Signed for cdn.example.com, try evilcdn.example.com
req.SourceHost = "evilcdn.example.com"
},
},
{
name: "different path does not match",
tamper: func(req *Request) {
req.SourcePath = "/photos/dog.jpg"
},
},
{
name: "path suffix does not match",
tamper: func(req *Request) {
req.SourcePath = "/photos/cat.jpg/extra"
},
},
{
name: "path prefix does not match",
tamper: func(req *Request) {
req.SourcePath = "/other/photos/cat.jpg"
},
},
{
name: "different query does not match",
tamper: func(req *Request) {
req.SourceQuery = "token=xyz"
},
},
{
name: "added query does not match empty query",
tamper: func(req *Request) {
req.SourceQuery = "extra=1"
},
},
{
name: "removed query does not match",
tamper: func(req *Request) {
req.SourceQuery = ""
},
},
{
name: "different width does not match",
tamper: func(req *Request) {
req.Width = 801
},
},
{
name: "different height does not match",
tamper: func(req *Request) {
req.Height = 601
},
},
{
name: "different format does not match",
tamper: func(req *Request) {
req.Format = "png"
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := baseReq() req := baseReq()
tt.tamper(req) tt.tamper(req)
err := signer.Verify(req) err := signer.Verify(req)
if err != ErrInvalid { if !errors.Is(err, signature.ErrInvalid) {
t.Errorf("Verify() = %v, want %v", err, ErrInvalid) t.Errorf("Verify() = %v, want %v", err, signature.ErrInvalid)
} }
}) })
} }
// Verify the unmodified base request still passes // Verify the unmodified base request still passes
t.Run("unmodified request passes", func(t *testing.T) { t.Run("unmodified request passes", func(t *testing.T) {
t.Parallel()
req := baseReq() req := baseReq()
if err := signer.Verify(req); err != nil {
err := signer.Verify(req)
if err != nil {
t.Errorf("Verify() unmodified request failed: %v", err) t.Errorf("Verify() unmodified request failed: %v", err)
} }
}) })
@@ -302,10 +292,12 @@ func TestSigner_Verify_ExactMatchOnly(t *testing.T) {
// string in the signature data, producing different signatures for // string in the signature data, producing different signatures for
// suffix-related hosts. // suffix-related hosts.
func TestSigner_Sign_ExactHostInData(t *testing.T) { func TestSigner_Sign_ExactHostInData(t *testing.T) {
signer := New("test-secret-key") t.Parallel()
signer := signature.New("test-secret-key")
hosts := []string{ hosts := []string{
"cdn.example.com", testHost,
"example.com", "example.com",
"images.example.com", "images.example.com",
"images.cdn.example.com", "images.cdn.example.com",
@@ -315,13 +307,13 @@ func TestSigner_Sign_ExactHostInData(t *testing.T) {
sigs := make(map[string]string) sigs := make(map[string]string)
for _, host := range hosts { for _, host := range hosts {
req := &Request{ req := &signature.Request{
SourceHost: host, SourceHost: host,
SourcePath: "/photos/cat.jpg", SourcePath: testPath,
SourceQuery: "", SourceQuery: "",
Width: 800, Width: 800,
Height: 600, Height: 600,
Format: "webp", Format: testFormatWebP,
Expires: time.Unix(1704067200, 0), Expires: time.Unix(1704067200, 0),
} }
@@ -335,15 +327,17 @@ func TestSigner_Sign_ExactHostInData(t *testing.T) {
} }
func TestSigner_DifferentKeys(t *testing.T) { func TestSigner_DifferentKeys(t *testing.T) {
signer1 := New("secret-key-1") t.Parallel()
signer2 := New("secret-key-2")
req := &Request{ signer1 := signature.New("secret-key-1")
SourceHost: "cdn.example.com", signer2 := signature.New("secret-key-2")
SourcePath: "/photos/cat.jpg",
req := &signature.Request{
SourceHost: testHost,
SourcePath: testPath,
Width: 800, Width: 800,
Height: 600, Height: 600,
Format: "webp", Format: testFormatWebP,
Expires: time.Now().Add(1 * time.Hour), Expires: time.Now().Add(1 * time.Hour),
} }
@@ -351,35 +345,38 @@ func TestSigner_DifferentKeys(t *testing.T) {
req.Signature = signer1.Sign(req) req.Signature = signer1.Sign(req)
// Verify with key 1 should succeed // Verify with key 1 should succeed
if err := signer1.Verify(req); err != nil { err := signer1.Verify(req)
if err != nil {
t.Errorf("Verify() with same key failed: %v", err) t.Errorf("Verify() with same key failed: %v", err)
} }
// Verify with key 2 should fail // Verify with key 2 should fail
if err := signer2.Verify(req); err != ErrInvalid { err = signer2.Verify(req)
if !errors.Is(err, signature.ErrInvalid) {
t.Errorf("Verify() with different key should fail, got: %v", err) t.Errorf("Verify() with different key should fail, got: %v", err)
} }
} }
func TestGenerateSignedURL(t *testing.T) { func TestGenerateSignedURL(t *testing.T) {
signer := New("test-secret-key") t.Parallel()
req := &Request{ signer := signature.New("test-secret-key")
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg", req := &signature.Request{
SourceHost: testHost,
SourcePath: testPath,
SourceQuery: "", SourceQuery: "",
Width: 800, Width: 800,
Height: 600, Height: 600,
Format: "webp", Format: testFormatWebP,
} }
ttl := 1 * time.Hour ttl := 1 * time.Hour
path, sig, exp := signer.GenerateSignedURL(req, ttl) path, sig, exp := signer.GenerateSignedURL(req, ttl)
// Path should be correct format // Path should be correct format
expectedPath := "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp" if path != testSignedPath {
if path != expectedPath { t.Errorf("GenerateSignedURL() path = %q, want %q", path, testSignedPath)
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expectedPath)
} }
// Signature should be non-empty // Signature should be non-empty
@@ -389,6 +386,7 @@ func TestGenerateSignedURL(t *testing.T) {
// Expiration should be approximately now + TTL // Expiration should be approximately now + TTL
expTime := time.Unix(exp, 0) expTime := time.Unix(exp, 0)
expectedExp := time.Now().Add(ttl) expectedExp := time.Now().Add(ttl)
if expTime.Sub(expectedExp) > time.Second { if expTime.Sub(expectedExp) > time.Second {
t.Errorf("GenerateSignedURL() exp time off by too much") t.Errorf("GenerateSignedURL() exp time off by too much")
@@ -401,14 +399,16 @@ func TestGenerateSignedURL(t *testing.T) {
} }
func TestGenerateSignedURL_OrigSize(t *testing.T) { func TestGenerateSignedURL_OrigSize(t *testing.T) {
signer := New("test-secret-key") t.Parallel()
req := &Request{ signer := signature.New("test-secret-key")
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg", req := &signature.Request{
SourceHost: testHost,
SourcePath: testPath,
Width: 0, // Original size Width: 0, // Original size
Height: 0, Height: 0,
Format: "png", Format: testFormatPNG,
} }
path, _, _ := signer.GenerateSignedURL(req, time.Hour) path, _, _ := signer.GenerateSignedURL(req, time.Hour)
@@ -420,21 +420,24 @@ func TestGenerateSignedURL_OrigSize(t *testing.T) {
} }
func TestGenerateSignedURL_WithQueryString(t *testing.T) { func TestGenerateSignedURL_WithQueryString(t *testing.T) {
signer := New("test-secret-key-for-testing!") t.Parallel()
req := &Request{ signer := signature.New("test-secret-key-for-testing!")
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg", req := &signature.Request{
SourceHost: testHost,
SourcePath: testPath,
SourceQuery: "token=abc&v=2", SourceQuery: "token=abc&v=2",
Width: 800, Width: 800,
Height: 600, Height: 600,
Format: "webp", Format: testFormatWebP,
} }
path, _, _ := signer.GenerateSignedURL(req, time.Hour) path, _, _ := signer.GenerateSignedURL(req, time.Hour)
// The path must NOT contain a bare "?" that would be interpreted as a query string delimiter. // The path must NOT contain a bare "?" that would be interpreted as
// The size segment must appear as the last path component. // a query string delimiter. The size segment must appear as the last
// path component.
if strings.Contains(path, "?token=abc") { if strings.Contains(path, "?token=abc") {
t.Errorf("GenerateSignedURL() produced bare query string in path: %q", path) t.Errorf("GenerateSignedURL() produced bare query string in path: %q", path)
} }
@@ -451,25 +454,28 @@ func TestGenerateSignedURL_WithQueryString(t *testing.T) {
} }
func TestGenerateSignedURL_WithoutQueryString(t *testing.T) { func TestGenerateSignedURL_WithoutQueryString(t *testing.T) {
signer := New("test-secret-key-for-testing!") t.Parallel()
req := &Request{ signer := signature.New("test-secret-key-for-testing!")
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg", req := &signature.Request{
SourceHost: testHost,
SourcePath: testPath,
Width: 800, Width: 800,
Height: 600, Height: 600,
Format: "webp", Format: testFormatWebP,
} }
path, _, _ := signer.GenerateSignedURL(req, time.Hour) path, _, _ := signer.GenerateSignedURL(req, time.Hour)
expected := "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp" if path != testSignedPath {
if path != expected { t.Errorf("GenerateSignedURL() path = %q, want %q", path, testSignedPath)
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expected)
} }
} }
func TestParseParams(t *testing.T) { func TestParseParams(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
name string name string
sig string sig string
@@ -480,21 +486,21 @@ func TestParseParams(t *testing.T) {
}{ }{
{ {
name: "valid params", name: "valid params",
sig: "abc123", sig: testSig,
expStr: "1704067200", expStr: "1704067200",
wantSig: "abc123", wantSig: testSig,
wantErr: false, wantErr: false,
}, },
{ {
name: "empty expiration", name: "empty expiration",
sig: "abc123", sig: testSig,
expStr: "", expStr: "",
wantSig: "abc123", wantSig: testSig,
wantErr: false, wantErr: false,
}, },
{ {
name: "invalid expiration", name: "invalid expiration",
sig: "abc123", sig: testSig,
expStr: "not-a-number", expStr: "not-a-number",
wantErr: true, wantErr: true,
}, },
@@ -502,7 +508,9 @@ func TestParseParams(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
sig, exp, err := ParseParams(tt.sig, tt.expStr) t.Parallel()
sig, exp, err := signature.ParseParams(tt.sig, tt.expStr)
if tt.wantErr { if tt.wantErr {
if err == nil { if err == nil {

View File

@@ -11,11 +11,11 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-07-07. Never "latest"; exact versions only. # Pinned versions, 2026-08-07. Never "latest"; exact versions only.
GOLANGCI_LINT_VERSION="2.10.1" GOLANGCI_LINT_VERSION="2.12.2"
# sha256 of golangci-lint-2.10.1-linux-<arch>.tar.gz release archives # sha256 of golangci-lint-2.12.2-linux-<arch>.tar.gz release archives
GOLANGCI_LINT_SHA256_AMD64="dfa775874cf0561b404a02a8f4481fc69b28091da95aa697259820d429b09c99" GOLANGCI_LINT_SHA256_AMD64="8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553"
GOLANGCI_LINT_SHA256_ARM64="6652b42ae02915eb2f9cb2a2e0cac99514c8eded8388d88ae3e06e1a52c00de8" GOLANGCI_LINT_SHA256_ARM64="44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a"
PKGMGR="" PKGMGR=""
SUDO="" SUDO=""