Files
webhooker/internal/middleware/middleware_test.go
clawbot a0e4e32e3e
All checks were successful
check / check (push) Successful in 2m52s
Bound every slog line against client-chosen text (closes #176)
MaxBodySize logged r.URL.Path untruncated at WARN, and routes.go
registers it ahead of RequireAuth, so an unauthenticated
POST /source/<8 KB>/edit with an oversize declared Content-Length wrote
attacker-chosen text of attacker-chosen length into the operator's log,
for the cost of a request with no body. The 2,560-byte per-line budget
#146 established did not reach it: that budget lives in the access-log
field capping and this is a separate slog call.

The capping mechanism moves out of internal/middleware into
internal/logfield so there is one budget and one implementation rather
than a second ad-hoc truncation. Truncate and EncodedBytes are
unchanged; the access log now spends logfield.MaxBytes where it spent
maxLogFieldBytes.

The sweep the issue asked for found five more call sites of the same
shape, all reachable unauthenticated, all now capped: the CSRF 403
(also registered ahead of RequireAuth), the rate limiters' 429 (the
per-entrypoint receiver limiter is unauthenticated), RequireAuth's own
DEBUG line, the unknown-entrypoint DEBUG line on the receiver, and the
failed-login DEBUG lines. DEBUG being off by default is not a bound: an
operator turning it on to diagnose a flood must not thereby hand the
flood an unbounded write. Every other slog call in the tree was read
and judged; the PR body lists all of them, including the ones left
alone and why.

MaxBodySize stays ahead of RequireAuth. An oversize body should be
refused before the request buys a cookie decrypt and a session load,
and rejecting first is what keeps an unauthenticated flood from
choosing how much session work the process does. The ordering and what
it costs are now written at the registration, on maxFormBodySize.

MaxAccessLogLineBytes is restated as the ceiling on every slog line
carrying a client-supplied value, not just the access log's: each of
these lines carries strictly fewer client-supplied fields than the
access log does, so none can be wider. That is asserted per line under
both handlers rather than argued. Two writers are called out as NOT
covered, so the figure is not read as more than it is: the log delivery
target, which exists to emit the whole event and is deliberate, and
GORM's default logger, which prints the interpolated SQL to stdout on a
record-not-found and is unbounded on the receiver and login lookups.
That second one is a real defect this audit turned up and is filed
separately as #178, not fixed here.

Tests drive 8 KB of client-chosen text at all six sites, through both
handlers internal/logger can install and through each character they
escape — including a bare C0 control, which costs six bytes on the line
against the one it cost to send and is the case a raw-byte budget
breaks on first. Each holds the encoded line to the ceiling, holds the
whole flood's output to what that ceiling allows, and asserts the
markers at the far end of the input are absent, so a value that merely
happened to be short cannot pass. internal/logfield gains a test that
measures the per-rune charge against what the handlers really emit over
roughly 3,000 code points on each, so an undercharged rune fails a test
instead of quietly falsifying the ceiling.

Verified by mutation: reverting the MaxBodySize cap alone fails 12
subtests with a 16,583-byte line against the 2,560 ceiling; reverting
the other five fails 70; budgeting raw bytes instead of encoded ones
fails 23 across three packages.
2026-08-17 23:43:26 +00:00

1002 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,
}
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 := 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
}
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", 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)
}