check / check (push) Waiting to run
A route test now posts an oversized body with no session or CSRF token to each page route group, /settings included, and requires 413 with no CSRF cookie. Before, only the login form pinned the cap ahead of CSRF; reordering the /settings, /hooks or /hook groups failed nothing. The MaxBodySize doc comment says other methods pass uncapped on purpose, and the middleware test comment names the helper it describes. The three router helpers in the server tests build the Server through New, on a lifecycle that is never started, instead of setting its fields by hand. The README already described the cap's position correctly. Model: opus-5-5
1069 lines
24 KiB
Go
1069 lines
24 KiB
Go
package middleware_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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,
|
|
}
|
|
|
|
sessManager := newTestSessionManager(cfg, log, clock)
|
|
|
|
m := middleware.NewForTest(log, cfg, sessManager)
|
|
|
|
return m, sessManager, clock
|
|
}
|
|
|
|
// newTestSessionManager builds the real session.Session the
|
|
// middleware tests run against: an in-memory cookie store with a
|
|
// known key, and optionally a manually advanced clock.
|
|
func newTestSessionManager(
|
|
cfg *config.Config,
|
|
log *slog.Logger,
|
|
clock *fakeClock,
|
|
) *session.Session {
|
|
key := make([]byte, testKeySize)
|
|
|
|
for i := range key {
|
|
key[i] = byte(i)
|
|
}
|
|
|
|
store := session.NewStore(key)
|
|
|
|
var now func() time.Time
|
|
|
|
if clock != nil {
|
|
now = clock.Now
|
|
}
|
|
|
|
return session.NewForTest(store, cfg, log, key, now)
|
|
}
|
|
|
|
// 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?next=%2Fdashboard", w.Header().Get("Location"),
|
|
)
|
|
}
|
|
|
|
// TestRequireAuth_LoginRedirectCarriesOnlyAGet pins what the login
|
|
// redirect carries: a GET's path and query, so logging in can return
|
|
// there, and nothing for a POST, which a redirect cannot repeat.
|
|
func TestRequireAuth_LoginRedirectCarriesOnlyAGet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
handler := m.RequireAuth()(http.HandlerFunc(
|
|
func(_ http.ResponseWriter, _ *http.Request) {},
|
|
))
|
|
|
|
get := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/hook/abc/events?page=2", nil,
|
|
)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, get)
|
|
|
|
assert.Equal(
|
|
t, "/pages/login?next=%2Fhook%2Fabc%2Fevents%3Fpage%3D2",
|
|
w.Header().Get("Location"),
|
|
)
|
|
|
|
post := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodPost, "/hook/abc/delete", nil,
|
|
)
|
|
w = httptest.NewRecorder()
|
|
handler.ServeHTTP(w, post)
|
|
|
|
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
|
}
|
|
|
|
// TestRequireAuth_LoginRedirectLeavesOutALongURL: a GET whose path
|
|
// and query are longer than the login page accepts goes to the plain
|
|
// login page, so a long URL does not make the redirect long.
|
|
func TestRequireAuth_LoginRedirectLeavesOutALongURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
handler := m.RequireAuth()(http.HandlerFunc(
|
|
func(_ http.ResponseWriter, _ *http.Request) {},
|
|
))
|
|
|
|
atLimit := "/" + strings.Repeat("a", middleware.MaxNextBytes-1)
|
|
|
|
get := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, atLimit, nil,
|
|
)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, get)
|
|
|
|
assert.Equal(
|
|
t, "/pages/login?next=%2F"+atLimit[1:],
|
|
w.Header().Get("Location"),
|
|
)
|
|
|
|
get = httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, atLimit+"a", nil,
|
|
)
|
|
w = httptest.NewRecorder()
|
|
handler.ServeHTTP(w, get)
|
|
|
|
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?next=%2Fdashboard", 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?next=%2Fdashboard", 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, "/hooks", 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
|
|
|
|
// maxBodySizeResult is what runMaxBodySize's sentinel handler saw,
|
|
// together with the response.
|
|
type maxBodySizeResult struct {
|
|
called bool
|
|
read int
|
|
readErr error
|
|
response *httptest.ResponseRecorder
|
|
}
|
|
|
|
// runMaxBodySize wraps a sentinel handler in MaxBodySize with
|
|
// testBodyLimit and serves req through it. 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".
|
|
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 := session.NewStore(key)
|
|
|
|
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)
|
|
}
|