check / check (push) Canceled after 0s
The five counts the cache_stats table held are now Prometheus counters in memory, as fields of the cache. NewCache makes them and registers them on the registry it is given. pixad gives it Prometheus' default registry, which /metrics serves; each test gives it a new one. The Increment methods add to their cache's counters, so counting makes no database call. cache_stats, Cache.Stats, Service.Stats, CacheStats and the pending counts are gone, with their tests. The background goroutine now writes only access times, and its names say so: StartPendingAccessTimeWrites, StopPendingAccessTimeWrites and pendingaccesstimes.go. The two stop tests whose write under way was a count now use access times. Model: opus-5-5
292 lines
8.9 KiB
Go
292 lines
8.9 KiB
Go
package server
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"net/url"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"go.uber.org/fx/fxtest"
|
|
|
|
"sneak.berlin/go/pixa/internal/config"
|
|
"sneak.berlin/go/pixa/internal/database"
|
|
"sneak.berlin/go/pixa/internal/globals"
|
|
"sneak.berlin/go/pixa/internal/handlers"
|
|
"sneak.berlin/go/pixa/internal/healthcheck"
|
|
"sneak.berlin/go/pixa/internal/logger"
|
|
"sneak.berlin/go/pixa/internal/middleware"
|
|
)
|
|
|
|
// testSigningKey is a throwaway signing key; submitting it logs in.
|
|
const testSigningKey = "test-signing-key-0123456789abcdef"
|
|
|
|
// wrongKey is submitted for a failed login.
|
|
const wrongKey = "not-the-signing-key"
|
|
|
|
// Addresses for the login rate limit tests. The test server trusts
|
|
// 10.0.0.0/8 as its proxies, so the X-Forwarded-For sent by proxyPeer is
|
|
// believed and the one sent by firstClient or secondClient is ignored.
|
|
const (
|
|
firstClient = "198.51.100.1:40000"
|
|
secondClient = "198.51.100.2:40000"
|
|
proxyPeer = "10.0.0.1:40000"
|
|
firstForwarded = "203.0.113.1"
|
|
secondForwarded = "203.0.113.2"
|
|
)
|
|
|
|
// csrfFieldPattern extracts the CSRF token rendered into the login form.
|
|
var csrfFieldPattern = regexp.MustCompile(
|
|
`name="gorilla\.csrf\.Token" value="([^"]+)"`)
|
|
|
|
// newTestServer builds the server's real routes from the constructors
|
|
// cmd/pixad uses, with a throwaway state directory. Debug marks requests
|
|
// as plain HTTP, so the CSRF check runs without an https Referer.
|
|
func newTestServer(t *testing.T) *Server {
|
|
t.Helper()
|
|
|
|
stateDir := t.TempDir()
|
|
cfg := &config.Config{
|
|
Debug: true,
|
|
SigningKey: testSigningKey,
|
|
StateDir: stateDir,
|
|
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
|
|
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
|
DownstreamTimeout: config.DefaultDownstreamTimeout,
|
|
}
|
|
|
|
lc := fxtest.NewLifecycle(t)
|
|
|
|
log, err := logger.New(lc, logger.Params{Globals: &globals.Globals{}})
|
|
if err != nil {
|
|
t.Fatalf("logger.New() error = %v", err)
|
|
}
|
|
|
|
db, err := database.New(lc, database.Params{Logger: log, Config: cfg})
|
|
if err != nil {
|
|
t.Fatalf("database.New() error = %v", err)
|
|
}
|
|
|
|
hc, err := healthcheck.New(lc, healthcheck.Params{
|
|
Globals: &globals.Globals{}, Config: cfg, Logger: log, Database: db,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("healthcheck.New() error = %v", err)
|
|
}
|
|
|
|
h, err := handlers.New(lc, handlers.Params{
|
|
Logger: log, Healthcheck: hc, Database: db, Config: cfg,
|
|
MetricsRegistry: prometheus.NewRegistry(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("handlers.New() error = %v", err)
|
|
}
|
|
|
|
mw, err := middleware.New(lc, middleware.Params{Logger: log, Config: cfg})
|
|
if err != nil {
|
|
t.Fatalf("middleware.New() error = %v", err)
|
|
}
|
|
|
|
lc.RequireStart()
|
|
t.Cleanup(lc.RequireStop)
|
|
|
|
s := &Server{config: cfg, mw: mw, h: h}
|
|
s.SetupRoutes()
|
|
|
|
return s
|
|
}
|
|
|
|
// clientRequest builds a request for / arriving from remoteAddr, carrying
|
|
// forwardedFor as its X-Forwarded-For header when that is not empty.
|
|
func clientRequest(
|
|
t *testing.T, method string, body io.Reader, remoteAddr, forwardedFor string,
|
|
) *http.Request {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), method, "/", body)
|
|
req.RemoteAddr = remoteAddr
|
|
|
|
if forwardedFor != "" {
|
|
req.Header.Set("X-Forwarded-For", forwardedFor)
|
|
}
|
|
|
|
return req
|
|
}
|
|
|
|
// postLogin loads the login form with GET / and submits key in it with
|
|
// POST /, as a browser does, both from the same client. GET / is not rate
|
|
// limited, so the form must load even for a client over the limit.
|
|
func postLogin(
|
|
t *testing.T, s *Server, remoteAddr, forwardedFor, key string,
|
|
) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
page := httptest.NewRecorder()
|
|
s.ServeHTTP(page,
|
|
clientRequest(t, http.MethodGet, nil, remoteAddr, forwardedFor))
|
|
|
|
if page.Code != http.StatusOK {
|
|
t.Fatalf("GET / status = %d, want %d", page.Code, http.StatusOK)
|
|
}
|
|
|
|
match := csrfFieldPattern.FindStringSubmatch(page.Body.String())
|
|
if match == nil {
|
|
t.Fatalf("no CSRF token field found in the login form")
|
|
}
|
|
|
|
form := url.Values{"key": {key}, "gorilla.csrf.Token": {match[1]}}
|
|
req := clientRequest(t, http.MethodPost,
|
|
strings.NewReader(form.Encode()), remoteAddr, forwardedFor)
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
for _, c := range page.Result().Cookies() {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
s.ServeHTTP(rec, req)
|
|
|
|
return rec
|
|
}
|
|
|
|
// tripLoginRateLimit makes LoginAttemptsPerMinute failed logins from one
|
|
// client, each answered with the login form again, then one more, which
|
|
// must be refused with 429. It returns the response to that last attempt.
|
|
func tripLoginRateLimit(
|
|
t *testing.T, s *Server, remoteAddr, forwardedFor string,
|
|
) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
for attempt := range LoginAttemptsPerMinute {
|
|
rec := postLogin(t, s, remoteAddr, forwardedFor, wrongKey)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("failed login %d status = %d, want %d",
|
|
attempt+1, rec.Code, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
rec := postLogin(t, s, remoteAddr, forwardedFor, wrongKey)
|
|
if rec.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("login over the limit status = %d, want %d",
|
|
rec.Code, http.StatusTooManyRequests)
|
|
}
|
|
|
|
return rec
|
|
}
|
|
|
|
// TestLoginRateLimitRefusesAttemptOverLimit verifies the login attempt
|
|
// after LoginAttemptsPerMinute failed ones from one client is refused with
|
|
// 429 and a Retry-After header, and that the client cannot get around the
|
|
// limit by sending X-Forwarded-For: from a peer that is not a trusted
|
|
// proxy, the header is ignored.
|
|
func TestLoginRateLimitRefusesAttemptOverLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newTestServer(t)
|
|
|
|
rec := tripLoginRateLimit(t, s, firstClient, "")
|
|
|
|
retryAfter := rec.Header().Get("Retry-After")
|
|
|
|
seconds, err := strconv.Atoi(retryAfter)
|
|
if err != nil || seconds <= 0 {
|
|
t.Errorf("Retry-After = %q, want a positive number of seconds",
|
|
retryAfter)
|
|
}
|
|
|
|
rec = postLogin(t, s, firstClient, secondForwarded, wrongKey)
|
|
if rec.Code != http.StatusTooManyRequests {
|
|
t.Errorf("login with X-Forwarded-For from an untrusted peer "+
|
|
"status = %d, want %d", rec.Code, http.StatusTooManyRequests)
|
|
}
|
|
}
|
|
|
|
// TestLoginRateLimitLeavesOtherClientsAlone verifies one client going over
|
|
// the limit does not limit another: a failed login from a different
|
|
// address is answered with the login form, and the signing key still logs
|
|
// it in.
|
|
func TestLoginRateLimitLeavesOtherClientsAlone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newTestServer(t)
|
|
|
|
tripLoginRateLimit(t, s, firstClient, "")
|
|
|
|
rec := postLogin(t, s, secondClient, "", wrongKey)
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("failed login from another client status = %d, want %d",
|
|
rec.Code, http.StatusOK)
|
|
}
|
|
|
|
rec = postLogin(t, s, secondClient, "", testSigningKey)
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Errorf("login with the signing key from another client "+
|
|
"status = %d, want %d", rec.Code, http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
// TestLoginRateLimitCountsClientsBehindProxySeparately verifies the limit
|
|
// counts the client address resolved from X-Forwarded-For, not the address
|
|
// of the trusted proxy the requests arrive from, so two clients behind the
|
|
// same proxy are counted separately.
|
|
func TestLoginRateLimitCountsClientsBehindProxySeparately(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newTestServer(t)
|
|
|
|
tripLoginRateLimit(t, s, proxyPeer, firstForwarded)
|
|
|
|
rec := postLogin(t, s, proxyPeer, secondForwarded, wrongKey)
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("failed login from a second client behind the proxy "+
|
|
"status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// TestLoginRateLimitCountsIPv6ClientsByPrefix verifies an IPv6 client is
|
|
// counted by its /64: another address in the same /64 is refused too,
|
|
// while an address in a different /64 is not.
|
|
func TestLoginRateLimitCountsIPv6ClientsByPrefix(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newTestServer(t)
|
|
|
|
tripLoginRateLimit(t, s, proxyPeer, "2001:db8::1")
|
|
|
|
rec := postLogin(t, s, proxyPeer, "2001:db8::2", wrongKey)
|
|
if rec.Code != http.StatusTooManyRequests {
|
|
t.Errorf("login from the same /64 status = %d, want %d",
|
|
rec.Code, http.StatusTooManyRequests)
|
|
}
|
|
|
|
rec = postLogin(t, s, proxyPeer, "2001:db8:0:1::1", wrongKey)
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("login from another /64 status = %d, want %d",
|
|
rec.Code, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// TestLoginRateLimitCountsIPv4MappedClientsSeparately verifies an IPv4
|
|
// client that the proxy forwards in IPv4-mapped IPv6 form (::ffff:a.b.c.d)
|
|
// is counted by its IPv4 address, not by the /64 that every such address
|
|
// shares, so two of them behind the proxy are counted separately.
|
|
func TestLoginRateLimitCountsIPv4MappedClientsSeparately(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newTestServer(t)
|
|
|
|
tripLoginRateLimit(t, s, proxyPeer, "::ffff:"+firstForwarded)
|
|
|
|
rec := postLogin(t, s, proxyPeer, "::ffff:"+secondForwarded, wrongKey)
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("failed login from a second IPv4-mapped client "+
|
|
"status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
}
|