Some checks failed
check / check (push) Has been cancelled
CSRF ran before MaxBodySize, so the CSRF middleware parsed the form body
before any cap applied and an oversized request was read in full before
being rejected. MaxBodySize is now the first middleware in all four route
groups that parse forms, ahead of CSRF and RequireAuth.
An oversize request therefore gets 413 without the handler running and
without state changing, including the password-change route.
Note the ordering trade: an unauthenticated client now receives 413 rather
than an auth redirect on /user/{username}/password.
992 lines
22 KiB
Go
992 lines
22 KiB
Go
package middleware_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/sessions"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/middleware"
|
|
"sneak.berlin/go/webhooker/internal/session"
|
|
)
|
|
|
|
const testKeySize = 32
|
|
|
|
// testMiddleware creates a Middleware with minimal dependencies
|
|
// for testing. It uses a real session.Session backed by an
|
|
// in-memory cookie store.
|
|
func testMiddleware(
|
|
t *testing.T,
|
|
env string,
|
|
) (*middleware.Middleware, *session.Session) {
|
|
t.Helper()
|
|
|
|
m, s, _ := testMiddlewareWithSessionClock(t, env, 0, nil)
|
|
|
|
return m, s
|
|
}
|
|
|
|
// testMiddlewareWithSessionClock is testMiddleware with a
|
|
// configurable session idle timeout and a manually advanced clock,
|
|
// for the session-expiry tests. A nil clock uses the real one.
|
|
func testMiddlewareWithSessionClock(
|
|
t *testing.T,
|
|
env string,
|
|
idleTimeout time.Duration,
|
|
clock *fakeClock,
|
|
) (*middleware.Middleware, *session.Session, *fakeClock) {
|
|
t.Helper()
|
|
|
|
log := slog.New(slog.NewTextHandler(
|
|
os.Stderr,
|
|
&slog.HandlerOptions{Level: slog.LevelDebug},
|
|
))
|
|
|
|
cfg := &config.Config{
|
|
Environment: env,
|
|
SessionIdleTimeout: idleTimeout,
|
|
}
|
|
|
|
// Create a real session manager with a known key
|
|
key := make([]byte, testKeySize)
|
|
|
|
for i := range key {
|
|
key[i] = byte(i)
|
|
}
|
|
|
|
store := sessions.NewCookieStore(key)
|
|
store.Options = &sessions.Options{
|
|
Path: "/",
|
|
MaxAge: 86400 * 7,
|
|
HttpOnly: true,
|
|
Secure: false,
|
|
SameSite: http.SameSiteLaxMode,
|
|
}
|
|
|
|
var now func() time.Time
|
|
|
|
if clock != nil {
|
|
now = clock.Now
|
|
}
|
|
|
|
sessManager := session.NewForTest(store, cfg, log, key, now)
|
|
|
|
m := middleware.NewForTest(log, cfg, sessManager)
|
|
|
|
return m, sessManager, clock
|
|
}
|
|
|
|
// fakeClock is a manually advanced clock, so session expiry can be
|
|
// tested without sleeping.
|
|
type fakeClock struct {
|
|
t time.Time
|
|
}
|
|
|
|
func (c *fakeClock) Now() time.Time {
|
|
return c.t
|
|
}
|
|
|
|
func (c *fakeClock) Advance(d time.Duration) {
|
|
c.t = c.t.Add(d)
|
|
}
|
|
|
|
// newFakeClock returns a clock started at a fixed instant.
|
|
func newFakeClock() *fakeClock {
|
|
return &fakeClock{
|
|
t: time.Date(
|
|
2026, time.January, 2, 3, 4, 5, 0, time.UTC,
|
|
),
|
|
}
|
|
}
|
|
|
|
// --- Logging Middleware Tests ---
|
|
|
|
func TestLogging_SetsStatusCode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
handler := m.Logging()(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusCreated)
|
|
|
|
_, err := w.Write([]byte("created"))
|
|
if err != nil {
|
|
return
|
|
}
|
|
},
|
|
))
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/test", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusCreated, w.Code)
|
|
assert.Equal(t, "created", w.Body.String())
|
|
}
|
|
|
|
func TestLogging_DefaultStatusOK(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
handler := m.Logging()(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
_, err := w.Write([]byte("ok"))
|
|
if err != nil {
|
|
return
|
|
}
|
|
},
|
|
))
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
// When no explicit WriteHeader is called, default is 200
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
|
|
func TestLogging_PassesThroughToNext(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
var called bool
|
|
|
|
handler := m.Logging()(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
))
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodPost, "/api/webhook", nil,
|
|
)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.True(
|
|
t, called,
|
|
"logging middleware should call the next handler",
|
|
)
|
|
}
|
|
|
|
// --- LoggingResponseWriter Tests ---
|
|
|
|
func TestLoggingResponseWriter_CapturesStatusCode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
w := httptest.NewRecorder()
|
|
lrw := middleware.NewLoggingResponseWriterForTest(w)
|
|
|
|
// Default should be 200
|
|
assert.Equal(
|
|
t, http.StatusOK,
|
|
middleware.LoggingResponseWriterStatusCode(lrw),
|
|
)
|
|
|
|
// WriteHeader should capture the status code
|
|
lrw.WriteHeader(http.StatusNotFound)
|
|
|
|
assert.Equal(
|
|
t, http.StatusNotFound,
|
|
middleware.LoggingResponseWriterStatusCode(lrw),
|
|
)
|
|
|
|
// Underlying writer should also get the status code
|
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
}
|
|
|
|
func TestLoggingResponseWriter_WriteDelegatesToUnderlying(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
w := httptest.NewRecorder()
|
|
lrw := middleware.NewLoggingResponseWriterForTest(w)
|
|
|
|
n, err := lrw.Write([]byte("hello world"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 11, n)
|
|
assert.Equal(t, "hello world", w.Body.String())
|
|
}
|
|
|
|
// --- CORS Middleware Tests ---
|
|
|
|
func TestCORS_DevMode_AllowsAnyOrigin(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
handler := m.CORS()(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
))
|
|
|
|
// Preflight request
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodOptions, "/api/test", nil,
|
|
)
|
|
req.Header.Set("Origin", "http://localhost:3000")
|
|
req.Header.Set("Access-Control-Request-Method", "POST")
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
// In dev mode, CORS should allow any origin
|
|
assert.Equal(
|
|
t, "*",
|
|
w.Header().Get("Access-Control-Allow-Origin"),
|
|
)
|
|
}
|
|
|
|
func TestCORS_ProdMode_NoOp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentProd)
|
|
|
|
var called bool
|
|
|
|
handler := m.CORS()(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
))
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/api/test", nil,
|
|
)
|
|
req.Header.Set("Origin", "http://evil.com")
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.True(
|
|
t, called,
|
|
"prod CORS middleware should pass through to handler",
|
|
)
|
|
// In prod, no CORS headers should be set (no-op middleware)
|
|
assert.Empty(
|
|
t,
|
|
w.Header().Get("Access-Control-Allow-Origin"),
|
|
"prod mode should not set CORS headers",
|
|
)
|
|
}
|
|
|
|
// --- RequireAuth Middleware Tests ---
|
|
|
|
func TestRequireAuth_NoSession_RedirectsToLogin(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
var called bool
|
|
|
|
handler := m.RequireAuth()(http.HandlerFunc(
|
|
func(_ http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
},
|
|
))
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/dashboard", nil,
|
|
)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.False(
|
|
t, called,
|
|
"handler should not be called for "+
|
|
"unauthenticated request",
|
|
)
|
|
assert.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
|
}
|
|
|
|
func TestRequireAuth_AuthenticatedSession_PassesThrough(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
m, sessManager := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
var called bool
|
|
|
|
handler := m.RequireAuth()(http.HandlerFunc(
|
|
func(_ http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
},
|
|
))
|
|
|
|
// Create an authenticated session by making a request,
|
|
// setting session data, and saving the session cookie
|
|
setupReq := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/setup", nil,
|
|
)
|
|
setupW := httptest.NewRecorder()
|
|
|
|
sess, err := sessManager.Get(setupReq)
|
|
require.NoError(t, err)
|
|
sessManager.SetUser(sess, "user-123", "testuser")
|
|
require.NoError(t, sessManager.Save(setupReq, setupW, sess))
|
|
|
|
// Extract the cookie from the setup response
|
|
cookies := setupW.Result().Cookies()
|
|
require.NotEmpty(t, cookies, "session cookie should be set")
|
|
|
|
// Make the actual request with the session cookie
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/dashboard", nil,
|
|
)
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.True(
|
|
t, called,
|
|
"handler should be called for authenticated request",
|
|
)
|
|
}
|
|
|
|
func TestRequireAuth_UnauthenticatedSession_RedirectsToLogin(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
m, sessManager := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
var called bool
|
|
|
|
handler := m.RequireAuth()(http.HandlerFunc(
|
|
func(_ http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
},
|
|
))
|
|
|
|
// Create a session but don't authenticate it
|
|
setupReq := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/setup", nil,
|
|
)
|
|
setupW := httptest.NewRecorder()
|
|
|
|
sess, err := sessManager.Get(setupReq)
|
|
require.NoError(t, err)
|
|
// Don't call SetUser -- session exists but is not
|
|
// authenticated
|
|
require.NoError(t, sessManager.Save(setupReq, setupW, sess))
|
|
|
|
cookies := setupW.Result().Cookies()
|
|
require.NotEmpty(t, cookies)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/dashboard", nil,
|
|
)
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.False(
|
|
t, called,
|
|
"handler should not be called for "+
|
|
"unauthenticated session",
|
|
)
|
|
assert.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
|
}
|
|
|
|
// --- RequireAuth Session Expiry Tests ---
|
|
|
|
// loginCookies authenticates a new session and returns the cookies
|
|
// a browser would then send back.
|
|
func loginCookies(
|
|
t *testing.T,
|
|
sessManager *session.Session,
|
|
) []*http.Cookie {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/login", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
sess, err := sessManager.Get(req)
|
|
require.NoError(t, err)
|
|
sessManager.SetUser(sess, "user-123", "testuser")
|
|
require.NoError(t, sessManager.Save(req, w, sess))
|
|
|
|
cookies := w.Result().Cookies()
|
|
require.NotEmpty(t, cookies, "session cookie should be set")
|
|
|
|
return cookies
|
|
}
|
|
|
|
// runAuthed sends a request carrying cookies through RequireAuth
|
|
// and reports whether the protected handler ran, plus the response.
|
|
func runAuthed(
|
|
t *testing.T,
|
|
m *middleware.Middleware,
|
|
cookies []*http.Cookie,
|
|
) (bool, *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
|
|
var called bool
|
|
|
|
handler := m.RequireAuth()(http.HandlerFunc(
|
|
func(_ http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
},
|
|
))
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/dashboard", nil,
|
|
)
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
return called, w
|
|
}
|
|
|
|
// sessionCookies filters a response's cookies down to the session
|
|
// cookie, so tests can tell whether the session was re-issued.
|
|
func sessionCookies(
|
|
w *httptest.ResponseRecorder,
|
|
) []*http.Cookie {
|
|
var out []*http.Cookie
|
|
|
|
for _, c := range w.Result().Cookies() {
|
|
if c.Name == session.SessionName {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func TestRequireAuth_IdleExpiredSession_RedirectsToLogin(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
idle := time.Hour
|
|
|
|
m, sessManager, clock := testMiddlewareWithSessionClock(
|
|
t, config.EnvironmentDev, idle, newFakeClock(),
|
|
)
|
|
|
|
cookies := loginCookies(t, sessManager)
|
|
|
|
clock.Advance(idle)
|
|
|
|
called, w := runAuthed(t, m, cookies)
|
|
|
|
assert.False(
|
|
t, called,
|
|
"handler should not run for an idle-expired session",
|
|
)
|
|
assert.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
|
assert.Empty(
|
|
t, sessionCookies(w),
|
|
"an expired session must not be refreshed",
|
|
)
|
|
}
|
|
|
|
func TestRequireAuth_RefreshesIdleDeadlineOnActivity(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
idle := time.Hour
|
|
|
|
m, sessManager, clock := testMiddlewareWithSessionClock(
|
|
t, config.EnvironmentDev, idle, newFakeClock(),
|
|
)
|
|
|
|
cookies := loginCookies(t, sessManager)
|
|
|
|
// Activity halfway through the idle window.
|
|
clock.Advance(idle / 2)
|
|
|
|
called, w := runAuthed(t, m, cookies)
|
|
require.True(t, called, "handler should run while valid")
|
|
|
|
refreshed := sessionCookies(w)
|
|
require.NotEmpty(
|
|
t, refreshed,
|
|
"activity should re-issue the session cookie",
|
|
)
|
|
|
|
// Past the original deadline. The refreshed cookie is still
|
|
// good; the original one is not.
|
|
clock.Advance(idle - time.Second)
|
|
|
|
calledRefreshed, _ := runAuthed(t, m, refreshed)
|
|
assert.True(
|
|
t, calledRefreshed,
|
|
"refreshed session should outlive the original deadline",
|
|
)
|
|
|
|
calledStale, staleW := runAuthed(t, m, cookies)
|
|
assert.False(
|
|
t, calledStale,
|
|
"the pre-refresh cookie carries the old idle deadline",
|
|
)
|
|
assert.Equal(t, http.StatusSeeOther, staleW.Code)
|
|
}
|
|
|
|
func TestRequireAuth_UnauthenticatedRequestDoesNotRefresh(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
m, sessManager, _ := testMiddlewareWithSessionClock(
|
|
t, config.EnvironmentDev, time.Hour, newFakeClock(),
|
|
)
|
|
|
|
// A session cookie that exists but was never authenticated.
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/setup", nil)
|
|
setupW := httptest.NewRecorder()
|
|
|
|
sess, err := sessManager.Get(req)
|
|
require.NoError(t, err)
|
|
require.NoError(t, sessManager.Save(req, setupW, sess))
|
|
|
|
cookies := setupW.Result().Cookies()
|
|
require.NotEmpty(t, cookies)
|
|
|
|
called, w := runAuthed(t, m, cookies)
|
|
|
|
assert.False(t, called)
|
|
assert.Empty(
|
|
t, sessionCookies(w),
|
|
"an unauthenticated request must not stamp the session",
|
|
)
|
|
}
|
|
|
|
// --- NoCache Middleware Tests ---
|
|
|
|
func TestNoCache_SetsHeaders(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
var called bool
|
|
|
|
handler := m.NoCache()(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
))
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/sources", nil,
|
|
)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.True(
|
|
t, called,
|
|
"NoCache middleware should call the next handler",
|
|
)
|
|
assert.Equal(
|
|
t, "no-store",
|
|
w.Header().Get("Cache-Control"),
|
|
)
|
|
assert.Equal(
|
|
t, "no-cache",
|
|
w.Header().Get("Pragma"),
|
|
)
|
|
}
|
|
|
|
// --- MaxBodySize Middleware Tests ---
|
|
|
|
const testBodyLimit int64 = 64
|
|
|
|
// maxBodySizeHandler wraps a sentinel handler in MaxBodySize with
|
|
// testBodyLimit. The sentinel records whether it ran and how much of
|
|
// the body it managed to read, so tests can distinguish "never
|
|
// reached" from "reached but truncated".
|
|
type maxBodySizeResult struct {
|
|
called bool
|
|
read int
|
|
readErr error
|
|
response *httptest.ResponseRecorder
|
|
}
|
|
|
|
func runMaxBodySize(
|
|
t *testing.T,
|
|
req *http.Request,
|
|
) *maxBodySizeResult {
|
|
t.Helper()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
res := &maxBodySizeResult{response: httptest.NewRecorder()}
|
|
|
|
handler := m.MaxBodySize(testBodyLimit)(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
res.called = true
|
|
|
|
body, err := io.ReadAll(r.Body)
|
|
res.read = len(body)
|
|
res.readErr = err
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
))
|
|
|
|
handler.ServeHTTP(res.response, req)
|
|
|
|
return res
|
|
}
|
|
|
|
// postWithBody builds a POST request whose Content-Length is
|
|
// accurate for the given payload size.
|
|
func postWithBody(size int) *http.Request {
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodPost, "/pages/login",
|
|
strings.NewReader(strings.Repeat("a", size)),
|
|
)
|
|
req.Header.Set(
|
|
"Content-Type", "application/x-www-form-urlencoded",
|
|
)
|
|
|
|
return req
|
|
}
|
|
|
|
func TestMaxBodySize_DeclaredOversize_413AndHandlerNotReached(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
res := runMaxBodySize(t, postWithBody(int(testBodyLimit)+1))
|
|
|
|
assert.False(
|
|
t, res.called,
|
|
"handler must not be reached for an oversized body",
|
|
)
|
|
assert.Equal(
|
|
t, http.StatusRequestEntityTooLarge, res.response.Code,
|
|
)
|
|
}
|
|
|
|
func TestMaxBodySize_AtLimit_PassesThrough(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
res := runMaxBodySize(t, postWithBody(int(testBodyLimit)))
|
|
|
|
assert.True(
|
|
t, res.called,
|
|
"handler should be reached for a body at the limit",
|
|
)
|
|
require.NoError(t, res.readErr)
|
|
assert.Equal(t, int(testBodyLimit), res.read)
|
|
assert.Equal(t, http.StatusOK, res.response.Code)
|
|
}
|
|
|
|
func TestMaxBodySize_UnderLimit_PassesThrough(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
res := runMaxBodySize(t, postWithBody(1))
|
|
|
|
assert.True(t, res.called)
|
|
require.NoError(t, res.readErr)
|
|
assert.Equal(t, 1, res.read)
|
|
assert.Equal(t, http.StatusOK, res.response.Code)
|
|
}
|
|
|
|
func TestMaxBodySize_GetWithOversizeBody_NotCapped(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/pages/login",
|
|
strings.NewReader(
|
|
strings.Repeat("a", int(testBodyLimit)+1),
|
|
),
|
|
)
|
|
|
|
res := runMaxBodySize(t, req)
|
|
|
|
assert.True(
|
|
t, res.called,
|
|
"GET requests are not subject to the POST body cap",
|
|
)
|
|
require.NoError(t, res.readErr)
|
|
assert.Equal(t, int(testBodyLimit)+1, res.read)
|
|
}
|
|
|
|
// TestMaxBodySize_UndeclaredOversize_TruncatedAtCap covers the
|
|
// chunked / lying-Content-Length case: there is nothing to check up
|
|
// front, so the request reaches the handler but MaxBytesReader
|
|
// hard-caps the body and the read fails at the limit.
|
|
func TestMaxBodySize_UndeclaredOversize_TruncatedAtCap(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
req := postWithBody(int(testBodyLimit) + 1)
|
|
// Simulate a chunked request: no declared length.
|
|
req.ContentLength = -1
|
|
|
|
res := runMaxBodySize(t, req)
|
|
|
|
assert.True(
|
|
t, res.called,
|
|
"an undeclared oversize body cannot be rejected up front",
|
|
)
|
|
require.Error(
|
|
t, res.readErr,
|
|
"reading past the cap must fail",
|
|
)
|
|
assert.Equal(
|
|
t, int(testBodyLimit), res.read,
|
|
"the handler must not see more than the cap",
|
|
)
|
|
}
|
|
|
|
// --- Helper Tests ---
|
|
|
|
func TestIpFromHostPort(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{"ipv4 with port", "192.168.1.1:8080", "192.168.1.1"},
|
|
{"ipv6 with port", "[::1]:8080", "::1"},
|
|
{"invalid format", "not-a-host-port", ""},
|
|
{"empty string", "", ""},
|
|
{"localhost", "127.0.0.1:80", "127.0.0.1"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
result := middleware.IPFromHostPort(tt.input)
|
|
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- MetricsAuth Tests ---
|
|
|
|
// metricsAuthMiddleware creates a Middleware configured for
|
|
// metrics auth testing. This helper de-duplicates the setup in
|
|
// metrics auth test functions.
|
|
func metricsAuthMiddleware(
|
|
t *testing.T,
|
|
) *middleware.Middleware {
|
|
t.Helper()
|
|
|
|
log := slog.New(slog.NewTextHandler(
|
|
os.Stderr,
|
|
&slog.HandlerOptions{Level: slog.LevelDebug},
|
|
))
|
|
|
|
cfg := &config.Config{
|
|
Environment: config.EnvironmentDev,
|
|
MetricsUsername: "admin",
|
|
MetricsPassword: "secret",
|
|
}
|
|
|
|
key := make([]byte, testKeySize)
|
|
store := sessions.NewCookieStore(key)
|
|
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
|
|
|
|
sessManager := session.NewForTest(store, cfg, log, key, nil)
|
|
|
|
return middleware.NewForTest(log, cfg, sessManager)
|
|
}
|
|
|
|
// runMetricsAuthRequest sends a GET /metrics request with the
|
|
// given basic-auth password through MetricsAuth and reports
|
|
// whether the wrapped handler ran plus the recorded response.
|
|
func runMetricsAuthRequest(
|
|
t *testing.T, password string,
|
|
) (bool, *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
|
|
m := metricsAuthMiddleware(t)
|
|
|
|
var called bool
|
|
|
|
handler := m.MetricsAuth()(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
))
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/metrics", nil,
|
|
)
|
|
req.SetBasicAuth("admin", password)
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
return called, w
|
|
}
|
|
|
|
func TestMetricsAuth_ValidCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
called, w := runMetricsAuthRequest(t, "secret")
|
|
|
|
assert.True(
|
|
t, called,
|
|
"handler should be called with valid basic auth",
|
|
)
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
|
|
func TestMetricsAuth_InvalidCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
called, w := runMetricsAuthRequest(t, "wrong-password")
|
|
|
|
assert.False(
|
|
t, called,
|
|
"handler should not be called with invalid basic auth",
|
|
)
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
}
|
|
|
|
func TestMetricsAuth_NoCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m := metricsAuthMiddleware(t)
|
|
|
|
var called bool
|
|
|
|
handler := m.MetricsAuth()(http.HandlerFunc(
|
|
func(_ http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
},
|
|
))
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/metrics", nil,
|
|
)
|
|
// No basic auth header
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.False(
|
|
t, called,
|
|
"handler should not be called without credentials",
|
|
)
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
}
|
|
|
|
// --- CORS Dev Mode Detailed Tests ---
|
|
|
|
func TestCORS_DevMode_AllowsMethods(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
handler := m.CORS()(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
))
|
|
|
|
// Preflight for POST
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodOptions, "/api/webhooks", nil,
|
|
)
|
|
req.Header.Set("Origin", "http://localhost:5173")
|
|
req.Header.Set("Access-Control-Request-Method", "POST")
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
allowMethods := w.Header().Get("Access-Control-Allow-Methods")
|
|
assert.Contains(t, allowMethods, "POST")
|
|
}
|
|
|
|
// --- Base64 key validation for completeness ---
|
|
|
|
func TestSessionKeyFormat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Verify that the session initialization correctly validates
|
|
// key format. A proper 32-byte key encoded as base64 should
|
|
// work.
|
|
key := make([]byte, testKeySize)
|
|
|
|
for i := range key {
|
|
key[i] = byte(i + 1)
|
|
}
|
|
|
|
encoded := base64.StdEncoding.EncodeToString(key)
|
|
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
|
require.NoError(t, err)
|
|
assert.Len(t, decoded, testKeySize)
|
|
}
|