All checks were successful
check / check (push) Successful in 5s
Closes [issue #50](#50) ## Summary Refactors the Dockerfile to use a separate lint stage with a pinned golangci-lint Docker image, following the pattern used by [sneak/pixa](https://git.eeqj.de/sneak/pixa). This replaces the previous approach of installing golangci-lint via curl in the builder stage. ## Changes ### Dockerfile - **New `lint` stage** using `golangci/golangci-lint:v2.11.3` (Debian-based, pinned by sha256 digest) as a separate build stage - **Builder stage** depends on lint via `COPY --from=lint /src/go.sum /dev/null` — build won't proceed unless linting passes - **Go bumped** from 1.24 to 1.26.1 (`golang:1.26.1-bookworm`, pinned by sha256) - **golangci-lint bumped** from v1.64.8 to v2.11.3 - All three Docker images (golangci-lint, golang, alpine) pinned by sha256 digest - Debian-based golangci-lint image used (not Alpine) because mattn/go-sqlite3 CGO does not compile on musl (off64_t) ### Linter Config (.golangci.yml) - Migrated from v1 to v2 format (`version: "2"` added) - Removed linters no longer available in v2: `gofmt` (handled by `make fmt-check`), `gosimple` (merged into `staticcheck`), `typecheck` (always-on in v2) - Same set of linters enabled — no rules weakened ### Code Fixes (all lint issues from v2 upgrade) - Added package comments to all packages - Added doc comments to all exported types, functions, and methods - Fixed unchecked errors flagged by `errcheck` (sqlDB.Close, os.Setenv in tests, resp.Body.Close, fmt.Fprint) - Fixed unused parameters flagged by `revive` (renamed to `_`) - Fixed `gosec` G120 warnings: added `http.MaxBytesReader` before `r.ParseForm()` calls - Fixed `staticcheck` QF1012: replaced `WriteString(fmt.Sprintf(...))` with `fmt.Fprintf` - Fixed `staticcheck` QF1003: converted if/else chain to tagged switch - Renamed `DeliveryTask` → `Task` to avoid package stutter (`delivery.Task` instead of `delivery.DeliveryTask`) - Renamed shadowed builtin `max` parameter to `upperBound` in `cryptoRandInt` - Used `t.Setenv` instead of `os.Setenv` in tests (auto-restores) ### README.md - Updated version requirements: Go 1.26+, golangci-lint v2.11+ - Updated Dockerfile description in project structure ## Verification `docker build .` passes cleanly — formatting check, linting, all tests, and build all succeed. Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de> Reviewed-on: #55 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
589 lines
12 KiB
Go
589 lines
12 KiB
Go
package middleware_test
|
|
|
|
import (
|
|
"context"
|
|
"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/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()
|
|
|
|
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, 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,
|
|
}
|
|
|
|
sessManager := session.NewForTest(store, cfg, log, key)
|
|
|
|
m := middleware.NewForTest(log, cfg, sessManager)
|
|
|
|
return m, 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", 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"))
|
|
}
|
|
|
|
// --- 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)
|
|
|
|
return middleware.NewForTest(log, cfg, sessManager)
|
|
}
|
|
|
|
func TestMetricsAuth_ValidCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
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", "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()
|
|
|
|
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", "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()
|
|
|
|
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)
|
|
}
|