All checks were successful
check / check (push) Successful in 4s
Add comprehensive test coverage for three previously-untested packages: delivery (37% → 75%): - processNewTask with inline and large (DB-fetched) bodies - processRetryTask success, skip non-retrying, large body fetch - Worker lifecycle start/stop, retry channel processing - processDelivery unknown target type handling - recoverPendingDeliveries, recoverWebhookDeliveries, recoverInFlight - HTTP delivery with custom headers, timeout, invalid config - Notify batching middleware (0% → 70%): - Logging middleware status code capture and pass-through - LoggingResponseWriter delegation - CORS dev mode (allow-all) and prod mode (no-op) - RequireAuth redirect for unauthenticated, pass-through for authenticated - MetricsAuth basic auth validation - ipFromHostPort helper session (0% → 52%): - Get/Save round-trip with real cookie store - SetUser, GetUserID, GetUsername, IsAuthenticated - ClearUser removes all keys - Destroy invalidates session (MaxAge -1) - Session persistence across requests - Edge cases: overwrite user, wrong type, constants Test helpers added: - database.NewTestDatabase / NewTestWebhookDBManager for cross-package testing - session.NewForTest for middleware tests without fx lifecycle closes #28
472 lines
12 KiB
Go
472 lines
12 KiB
Go
package middleware
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
|
|
"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/session"
|
|
)
|
|
|
|
// 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, *session.Session) {
|
|
t.Helper()
|
|
|
|
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
|
|
|
cfg := &config.Config{
|
|
Environment: env,
|
|
}
|
|
|
|
// Create a real session manager with a known key
|
|
key := make([]byte, 32)
|
|
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,
|
|
}
|
|
|
|
sessManager := newTestSession(t, store, cfg, log)
|
|
|
|
m := &Middleware{
|
|
log: log,
|
|
params: &MiddlewareParams{
|
|
Config: cfg,
|
|
},
|
|
session: sessManager,
|
|
}
|
|
|
|
return m, sessManager
|
|
}
|
|
|
|
// newTestSession creates a session.Session with a pre-configured cookie store
|
|
// for testing. This avoids needing the fx lifecycle and database.
|
|
func newTestSession(t *testing.T, store *sessions.CookieStore, cfg *config.Config, log *slog.Logger) *session.Session {
|
|
t.Helper()
|
|
return session.NewForTest(store, cfg, log)
|
|
}
|
|
|
|
// --- 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)
|
|
if _, err := w.Write([]byte("created")); err != nil {
|
|
return
|
|
}
|
|
}))
|
|
|
|
req := httptest.NewRequest(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) {
|
|
if _, err := w.Write([]byte("ok")); err != nil {
|
|
return
|
|
}
|
|
}))
|
|
|
|
req := httptest.NewRequest(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.NewRequest(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 := NewLoggingResponseWriter(w)
|
|
|
|
// Default should be 200
|
|
assert.Equal(t, http.StatusOK, lrw.statusCode)
|
|
|
|
// WriteHeader should capture the status code
|
|
lrw.WriteHeader(http.StatusNotFound)
|
|
assert.Equal(t, http.StatusNotFound, lrw.statusCode)
|
|
|
|
// 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 := NewLoggingResponseWriter(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.NewRequest(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.NewRequest(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.NewRequest(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.NewRequest(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.NewRequest(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.NewRequest(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.NewRequest(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"))
|
|
}
|
|
|
|
// --- 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 := ipFromHostPort(tt.input)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- MetricsAuth Tests ---
|
|
|
|
func TestMetricsAuth_ValidCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
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, 32)
|
|
store := sessions.NewCookieStore(key)
|
|
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
|
|
|
|
sessManager := session.NewForTest(store, cfg, log)
|
|
|
|
m := &Middleware{
|
|
log: log,
|
|
params: &MiddlewareParams{
|
|
Config: cfg,
|
|
},
|
|
session: sessManager,
|
|
}
|
|
|
|
var called bool
|
|
handler := m.MetricsAuth()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
|
req.SetBasicAuth("admin", "secret")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
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()
|
|
|
|
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, 32)
|
|
store := sessions.NewCookieStore(key)
|
|
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
|
|
|
|
sessManager := session.NewForTest(store, cfg, log)
|
|
|
|
m := &Middleware{
|
|
log: log,
|
|
params: &MiddlewareParams{
|
|
Config: cfg,
|
|
},
|
|
session: sessManager,
|
|
}
|
|
|
|
var called bool
|
|
handler := m.MetricsAuth()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
|
req.SetBasicAuth("admin", "wrong-password")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
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()
|
|
|
|
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, 32)
|
|
store := sessions.NewCookieStore(key)
|
|
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
|
|
|
|
sessManager := session.NewForTest(store, cfg, log)
|
|
|
|
m := &Middleware{
|
|
log: log,
|
|
params: &MiddlewareParams{
|
|
Config: cfg,
|
|
},
|
|
session: sessManager,
|
|
}
|
|
|
|
var called bool
|
|
handler := m.MetricsAuth()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
}))
|
|
|
|
req := httptest.NewRequest(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.NewRequest(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, 32)
|
|
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, 32)
|
|
}
|