test: add tests for delivery, middleware, and session packages
All checks were successful
check / check (push) Successful in 4s
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
This commit is contained in:
28
internal/database/testing.go
Normal file
28
internal/database/testing.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewTestDatabase creates a Database wrapper around a pre-opened *gorm.DB.
|
||||||
|
// Intended for use in tests that need a *database.Database without the
|
||||||
|
// full fx lifecycle. The caller is responsible for closing the underlying
|
||||||
|
// sql.DB connection.
|
||||||
|
func NewTestDatabase(db *gorm.DB) *Database {
|
||||||
|
return &Database{
|
||||||
|
db: db,
|
||||||
|
log: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTestWebhookDBManager creates a WebhookDBManager backed by the given
|
||||||
|
// data directory. Intended for use in tests without the fx lifecycle.
|
||||||
|
func NewTestWebhookDBManager(dataDir string) *WebhookDBManager {
|
||||||
|
return &WebhookDBManager{
|
||||||
|
dataDir: dataDir,
|
||||||
|
log: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})),
|
||||||
|
}
|
||||||
|
}
|
||||||
1023
internal/delivery/engine_integration_test.go
Normal file
1023
internal/delivery/engine_integration_test.go
Normal file
File diff suppressed because it is too large
Load Diff
471
internal/middleware/middleware_test.go
Normal file
471
internal/middleware/middleware_test.go
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
378
internal/session/session_test.go
Normal file
378
internal/session/session_test.go
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testSession creates a Session with a real cookie store for testing.
|
||||||
|
func testSession(t *testing.T) *Session {
|
||||||
|
t.Helper()
|
||||||
|
key := make([]byte, 32)
|
||||||
|
for i := range key {
|
||||||
|
key[i] = byte(i + 42)
|
||||||
|
}
|
||||||
|
store := sessions.NewCookieStore(key)
|
||||||
|
store.Options = &sessions.Options{
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: 86400 * 7,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: false,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Environment: config.EnvironmentDev,
|
||||||
|
}
|
||||||
|
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
|
||||||
|
return NewForTest(store, cfg, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Get and Save Tests ---
|
||||||
|
|
||||||
|
func TestGet_NewSession(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := testSession(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
sess, err := s.Get(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, sess)
|
||||||
|
assert.True(t, sess.IsNew, "session should be new when no cookie is present")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet_ExistingSession(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := testSession(t)
|
||||||
|
|
||||||
|
// Create and save a session
|
||||||
|
req1 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w1 := httptest.NewRecorder()
|
||||||
|
|
||||||
|
sess1, err := s.Get(req1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
sess1.Values["test_key"] = "test_value"
|
||||||
|
require.NoError(t, s.Save(req1, w1, sess1))
|
||||||
|
|
||||||
|
// Extract cookies
|
||||||
|
cookies := w1.Result().Cookies()
|
||||||
|
require.NotEmpty(t, cookies)
|
||||||
|
|
||||||
|
// Make a new request with the session cookie
|
||||||
|
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
for _, c := range cookies {
|
||||||
|
req2.AddCookie(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
sess2, err := s.Get(req2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, sess2.IsNew, "session should not be new when cookie is present")
|
||||||
|
assert.Equal(t, "test_value", sess2.Values["test_key"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSave_SetsCookie(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := testSession(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
sess, err := s.Get(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
sess.Values["key"] = "value"
|
||||||
|
|
||||||
|
err = s.Save(req, w, sess)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cookies := w.Result().Cookies()
|
||||||
|
require.NotEmpty(t, cookies, "Save should set a cookie")
|
||||||
|
|
||||||
|
// Verify the cookie has the expected name
|
||||||
|
var found bool
|
||||||
|
for _, c := range cookies {
|
||||||
|
if c.Name == SessionName {
|
||||||
|
found = true
|
||||||
|
assert.True(t, c.HttpOnly, "session cookie should be HTTP-only")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "should find a cookie named %s", SessionName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SetUser and User Retrieval Tests ---
|
||||||
|
|
||||||
|
func TestSetUser_SetsAllFields(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := testSession(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
sess, err := s.Get(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
s.SetUser(sess, "user-abc-123", "alice")
|
||||||
|
|
||||||
|
assert.Equal(t, "user-abc-123", sess.Values[UserIDKey])
|
||||||
|
assert.Equal(t, "alice", sess.Values[UsernameKey])
|
||||||
|
assert.Equal(t, true, sess.Values[AuthenticatedKey])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := testSession(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
sess, err := s.Get(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Before setting user
|
||||||
|
userID, ok := s.GetUserID(sess)
|
||||||
|
assert.False(t, ok, "should return false when no user ID is set")
|
||||||
|
assert.Empty(t, userID)
|
||||||
|
|
||||||
|
// After setting user
|
||||||
|
s.SetUser(sess, "user-xyz", "bob")
|
||||||
|
userID, ok = s.GetUserID(sess)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "user-xyz", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUsername(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := testSession(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
sess, err := s.Get(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Before setting user
|
||||||
|
username, ok := s.GetUsername(sess)
|
||||||
|
assert.False(t, ok, "should return false when no username is set")
|
||||||
|
assert.Empty(t, username)
|
||||||
|
|
||||||
|
// After setting user
|
||||||
|
s.SetUser(sess, "user-xyz", "bob")
|
||||||
|
username, ok = s.GetUsername(sess)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "bob", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- IsAuthenticated Tests ---
|
||||||
|
|
||||||
|
func TestIsAuthenticated_NoSession(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := testSession(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
sess, err := s.Get(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.False(t, s.IsAuthenticated(sess), "new session should not be authenticated")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAuthenticated_AfterSetUser(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := testSession(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
sess, err := s.Get(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
s.SetUser(sess, "user-123", "alice")
|
||||||
|
assert.True(t, s.IsAuthenticated(sess))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAuthenticated_AfterClearUser(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := testSession(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
sess, err := s.Get(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
s.SetUser(sess, "user-123", "alice")
|
||||||
|
require.True(t, s.IsAuthenticated(sess))
|
||||||
|
|
||||||
|
s.ClearUser(sess)
|
||||||
|
assert.False(t, s.IsAuthenticated(sess), "should not be authenticated after ClearUser")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAuthenticated_WrongType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := testSession(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
sess, err := s.Get(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set authenticated to a non-bool value
|
||||||
|
sess.Values[AuthenticatedKey] = "yes"
|
||||||
|
assert.False(t, s.IsAuthenticated(sess), "should return false for non-bool authenticated value")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ClearUser Tests ---
|
||||||
|
|
||||||
|
func TestClearUser_RemovesAllKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := testSession(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
sess, err := s.Get(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
s.SetUser(sess, "user-123", "alice")
|
||||||
|
s.ClearUser(sess)
|
||||||
|
|
||||||
|
_, hasUserID := sess.Values[UserIDKey]
|
||||||
|
assert.False(t, hasUserID, "UserIDKey should be removed")
|
||||||
|
|
||||||
|
_, hasUsername := sess.Values[UsernameKey]
|
||||||
|
assert.False(t, hasUsername, "UsernameKey should be removed")
|
||||||
|
|
||||||
|
_, hasAuth := sess.Values[AuthenticatedKey]
|
||||||
|
assert.False(t, hasAuth, "AuthenticatedKey should be removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Destroy Tests ---
|
||||||
|
|
||||||
|
func TestDestroy_InvalidatesSession(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := testSession(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
sess, err := s.Get(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
s.SetUser(sess, "user-123", "alice")
|
||||||
|
|
||||||
|
s.Destroy(sess)
|
||||||
|
|
||||||
|
// After Destroy: MaxAge should be -1 (delete cookie) and user data cleared
|
||||||
|
assert.Equal(t, -1, sess.Options.MaxAge, "Destroy should set MaxAge to -1")
|
||||||
|
assert.False(t, s.IsAuthenticated(sess), "should not be authenticated after Destroy")
|
||||||
|
|
||||||
|
_, hasUserID := sess.Values[UserIDKey]
|
||||||
|
assert.False(t, hasUserID, "Destroy should clear user ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Session Persistence Round-Trip ---
|
||||||
|
|
||||||
|
func TestSessionPersistence_RoundTrip(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := testSession(t)
|
||||||
|
|
||||||
|
// Step 1: Create session, set user, save
|
||||||
|
req1 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w1 := httptest.NewRecorder()
|
||||||
|
|
||||||
|
sess1, err := s.Get(req1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
s.SetUser(sess1, "user-round-trip", "charlie")
|
||||||
|
require.NoError(t, s.Save(req1, w1, sess1))
|
||||||
|
|
||||||
|
cookies := w1.Result().Cookies()
|
||||||
|
require.NotEmpty(t, cookies)
|
||||||
|
|
||||||
|
// Step 2: New request with cookies — session data should persist
|
||||||
|
req2 := httptest.NewRequest(http.MethodGet, "/profile", nil)
|
||||||
|
for _, c := range cookies {
|
||||||
|
req2.AddCookie(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
sess2, err := s.Get(req2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.True(t, s.IsAuthenticated(sess2), "session should be authenticated after round-trip")
|
||||||
|
|
||||||
|
userID, ok := s.GetUserID(sess2)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "user-round-trip", userID)
|
||||||
|
|
||||||
|
username, ok := s.GetUsername(sess2)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "charlie", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Constants Tests ---
|
||||||
|
|
||||||
|
func TestSessionConstants(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
assert.Equal(t, "webhooker_session", SessionName)
|
||||||
|
assert.Equal(t, "user_id", UserIDKey)
|
||||||
|
assert.Equal(t, "username", UsernameKey)
|
||||||
|
assert.Equal(t, "authenticated", AuthenticatedKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Edge Cases ---
|
||||||
|
|
||||||
|
func TestSetUser_OverwritesPreviousUser(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := testSession(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
sess, err := s.Get(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
s.SetUser(sess, "user-1", "alice")
|
||||||
|
assert.True(t, s.IsAuthenticated(sess))
|
||||||
|
|
||||||
|
// Overwrite with a different user
|
||||||
|
s.SetUser(sess, "user-2", "bob")
|
||||||
|
|
||||||
|
userID, ok := s.GetUserID(sess)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "user-2", userID)
|
||||||
|
|
||||||
|
username, ok := s.GetUsername(sess)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "bob", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDestroy_ThenSave_DeletesCookie(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := testSession(t)
|
||||||
|
|
||||||
|
// Create a session
|
||||||
|
req1 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w1 := httptest.NewRecorder()
|
||||||
|
|
||||||
|
sess, err := s.Get(req1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
s.SetUser(sess, "user-123", "alice")
|
||||||
|
require.NoError(t, s.Save(req1, w1, sess))
|
||||||
|
|
||||||
|
cookies := w1.Result().Cookies()
|
||||||
|
require.NotEmpty(t, cookies)
|
||||||
|
|
||||||
|
// Destroy and save
|
||||||
|
req2 := httptest.NewRequest(http.MethodGet, "/logout", nil)
|
||||||
|
for _, c := range cookies {
|
||||||
|
req2.AddCookie(c)
|
||||||
|
}
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
|
||||||
|
sess2, err := s.Get(req2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
s.Destroy(sess2)
|
||||||
|
require.NoError(t, s.Save(req2, w2, sess2))
|
||||||
|
|
||||||
|
// The cookie should have MaxAge = -1 (browser should delete it)
|
||||||
|
responseCookies := w2.Result().Cookies()
|
||||||
|
var sessionCookie *http.Cookie
|
||||||
|
for _, c := range responseCookies {
|
||||||
|
if c.Name == SessionName {
|
||||||
|
sessionCookie = c
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, sessionCookie, "should have a session cookie in response")
|
||||||
|
assert.True(t, sessionCookie.MaxAge < 0, "destroyed session cookie should have negative MaxAge")
|
||||||
|
}
|
||||||
19
internal/session/testing.go
Normal file
19
internal/session/testing.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewForTest creates a Session with a pre-configured cookie store for use
|
||||||
|
// in tests. This bypasses the fx lifecycle and database dependency, allowing
|
||||||
|
// middleware and handler tests to use real session functionality.
|
||||||
|
func NewForTest(store *sessions.CookieStore, cfg *config.Config, log *slog.Logger) *Session {
|
||||||
|
return &Session{
|
||||||
|
store: store,
|
||||||
|
config: cfg,
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user