check / check (push) Waiting to run
The test helpers lived in ordinary `testing.go` files inside the config, database, middleware and session packages, so they were built into the binary and the shared `test-support` lint rule could not see them. The four files are gone: the session's helpers move into its own `_test.go` file, and the rest into `configtest`, `databasetest` and `middlewaretest`, which the `depguard` deny list now names, so a non-test file importing them fails lint. The test-support packages build through the production constructors. Judgement call: the session, the middleware and the webhook database manager now take the plain logger they log through, which the application wiring provides. Judgement call: two idle-expiry tests move the stored timestamps back instead of advancing a fake clock. Model: opus-5-5
1085 lines
24 KiB
Go
1085 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"
|
|
"go.uber.org/fx/fxtest"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/middleware"
|
|
"sneak.berlin/go/webhooker/internal/middleware/middlewaretest"
|
|
"sneak.berlin/go/webhooker/internal/session"
|
|
)
|
|
|
|
const testKeySize = 32
|
|
|
|
// testMiddleware creates a Middleware with minimal dependencies
|
|
// for testing. It uses a real session.Session.
|
|
func testMiddleware(
|
|
t *testing.T,
|
|
env string,
|
|
) (*middleware.Middleware, *session.Session) {
|
|
t.Helper()
|
|
|
|
return testMiddlewareWithIdleTimeout(t, env, 0)
|
|
}
|
|
|
|
// testMiddlewareWithIdleTimeout is testMiddleware with a
|
|
// configurable session idle timeout, for the session-expiry tests.
|
|
func testMiddlewareWithIdleTimeout(
|
|
t *testing.T,
|
|
env string,
|
|
idleTimeout time.Duration,
|
|
) (*middleware.Middleware, *session.Session) {
|
|
t.Helper()
|
|
|
|
log := slog.New(slog.NewTextHandler(
|
|
os.Stderr,
|
|
&slog.HandlerOptions{Level: slog.LevelDebug},
|
|
))
|
|
|
|
cfg := &config.Config{
|
|
Environment: env,
|
|
SessionIdleTimeout: idleTimeout,
|
|
}
|
|
|
|
sessManager := newTestSessionManager(t, cfg)
|
|
|
|
m := middlewaretest.New(t, log, cfg, sessManager)
|
|
|
|
return m, sessManager
|
|
}
|
|
|
|
// newTestSessionManager builds the real session.Session the
|
|
// middleware tests run against, through session.New, with its key
|
|
// in a main database of its own.
|
|
func newTestSessionManager(
|
|
t *testing.T,
|
|
cfg *config.Config,
|
|
) *session.Session {
|
|
t.Helper()
|
|
|
|
discard := slog.New(slog.DiscardHandler)
|
|
|
|
db, err := database.Open(t.TempDir(), discard)
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
lc := fxtest.NewLifecycle(t)
|
|
|
|
sessManager, err := session.New(lc, session.Params{
|
|
Config: cfg,
|
|
Database: db,
|
|
Logger: discard,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// The start hook reads the key from db and builds the cookie
|
|
// store.
|
|
lc.RequireStart()
|
|
t.Cleanup(lc.RequireStop)
|
|
|
|
return sessManager
|
|
}
|
|
|
|
// --- 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
|
|
}
|
|
|
|
// aged re-issues the session cookie in cookies with both of its
|
|
// timestamps moved back by d: the cookie as it stands once d has
|
|
// passed, so session expiry can be tested without sleeping.
|
|
func aged(
|
|
t *testing.T,
|
|
sessManager *session.Session,
|
|
cookies []*http.Cookie,
|
|
d time.Duration,
|
|
) []*http.Cookie {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
sess, err := sessManager.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
for _, key := range []string{session.CreatedAtKey, session.LastSeenKey} {
|
|
at, ok := sess.Values[key].(int64)
|
|
require.True(t, ok, "the session has no %s", key)
|
|
|
|
sess.Values[key] = at - int64(d/time.Second)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
require.NoError(t, sessManager.Save(req, w, sess))
|
|
|
|
return sessionCookies(w)
|
|
}
|
|
|
|
func TestRequireAuth_IdleExpiredSession_RedirectsToLogin(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
idle := time.Hour
|
|
|
|
m, sessManager := testMiddlewareWithIdleTimeout(
|
|
t, config.EnvironmentDev, idle,
|
|
)
|
|
|
|
cookies := aged(t, sessManager, loginCookies(t, sessManager), 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 := testMiddlewareWithIdleTimeout(
|
|
t, config.EnvironmentDev, idle,
|
|
)
|
|
|
|
// Activity halfway through the idle window.
|
|
cookies := aged(t, sessManager, loginCookies(t, sessManager), 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. A minute short of the idle
|
|
// window leaves room for the real clock, which the session
|
|
// reads, to tick on while the test runs.
|
|
later := idle - time.Minute
|
|
|
|
calledRefreshed, _ := runAuthed(
|
|
t, m, aged(t, sessManager, refreshed, later),
|
|
)
|
|
assert.True(
|
|
t, calledRefreshed,
|
|
"refreshed session should outlive the original deadline",
|
|
)
|
|
|
|
calledStale, staleW := runAuthed(
|
|
t, m, aged(t, sessManager, cookies, later),
|
|
)
|
|
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 := testMiddlewareWithIdleTimeout(
|
|
t, config.EnvironmentDev, time.Hour,
|
|
)
|
|
|
|
// 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",
|
|
}
|
|
|
|
return middlewaretest.New(
|
|
t, log, cfg, newTestSessionManager(t, cfg),
|
|
)
|
|
}
|
|
|
|
// 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)
|
|
}
|