Files
kandev/apps/backend/internal/backendapp/persistence_middleware_test.go
T
2026-09-07 19:28:31 +01:00

167 lines
5.8 KiB
Go

package backendapp
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"github.com/kandev/kandev/internal/auth"
authhttpmw "github.com/kandev/kandev/internal/auth/httpmw"
authstore "github.com/kandev/kandev/internal/auth/store"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/persistence/requiredstores"
userstore "github.com/kandev/kandev/internal/user/store"
)
func TestRequiredPersistenceMiddlewareBlocksStatefulTrafficButAllowsDiagnostics(t *testing.T) {
gin.SetMode(gin.TestMode)
tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{{
ID: "task", OwnerPackage: "internal/task", RequiredTables: []string{"tasks"},
}})
if err != nil {
t.Fatalf("NewTracker: %v", err)
}
if err := tracker.RecordSuccess("task"); err != nil {
t.Fatalf("RecordSuccess: %v", err)
}
if err := tracker.RecordProbe("task", errTestPersistence); err != nil {
t.Fatalf("RecordProbe: %v", err)
}
router := gin.New()
router.Use(requiredPersistenceMiddleware(requiredstores.NewHealth(tracker, nil, nil)))
router.GET("/api/v1/tasks", func(c *gin.Context) { c.Status(http.StatusOK) })
router.GET("/api/v1/system/diagnostics/persistence", func(c *gin.Context) { c.Status(http.StatusOK) })
router.GET("/health", func(c *gin.Context) { c.Status(http.StatusOK) })
router.GET("/ready", func(c *gin.Context) { c.Status(http.StatusOK) })
router.GET("/ws", func(c *gin.Context) { c.Status(http.StatusOK) })
router.GET("/mcp", func(c *gin.Context) { c.Status(http.StatusOK) })
blocked := httptest.NewRecorder()
router.ServeHTTP(blocked, httptest.NewRequest(http.MethodPost, "/api/v1/tasks", nil))
if blocked.Code != http.StatusServiceUnavailable {
t.Fatalf("blocked status = %d, want %d", blocked.Code, http.StatusServiceUnavailable)
}
if blocked.Header().Get("Content-Type") == "" {
t.Fatal("blocked response has no content type")
}
for _, path := range []string{"/ws", "/mcp"} {
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("%s status = %d, want %d", path, rec.Code, http.StatusServiceUnavailable)
}
}
for _, path := range []string{
"/api/v1/system/diagnostics/persistence", "/health", "/ready",
} {
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Fatalf("%s status = %d, want %d", path, rec.Code, http.StatusOK)
}
}
}
func TestRequiredPersistenceMiddlewareRunsBeforeAuthentication(t *testing.T) {
gin.SetMode(gin.TestMode)
tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{{
ID: "task", OwnerPackage: "internal/task", RequiredTables: []string{"tasks"},
}})
if err != nil {
t.Fatalf("NewTracker: %v", err)
}
if err := tracker.RecordSuccess("task"); err != nil {
t.Fatalf("RecordSuccess: %v", err)
}
if err := tracker.RecordProbe("task", errTestPersistence); err != nil {
t.Fatalf("RecordProbe: %v", err)
}
authResolved := false
router := gin.New()
// This middleware stands in for auth-enabled token resolution. If the
// persistence gate is registered after it, a failed DB lookup becomes 401.
router.Use(requiredPersistenceMiddleware(requiredstores.NewHealth(tracker, nil, nil)))
router.Use(func(c *gin.Context) {
authResolved = true
c.AbortWithStatus(http.StatusUnauthorized)
})
router.GET("/api/v1/tasks", func(c *gin.Context) { c.Status(http.StatusOK) })
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
}
if authResolved {
t.Fatal("authentication middleware ran before the persistence gate")
}
}
func TestRequiredPersistenceMiddlewarePrecedesDatabaseBackedAuthentication(t *testing.T) {
gin.SetMode(gin.TestMode)
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
users, cleanup, err := userstore.Provide(conn, conn)
if err != nil {
_ = conn.Close()
t.Fatalf("user store: %v", err)
}
t.Cleanup(func() {
_ = cleanup()
_ = conn.Close()
})
store, err := authstore.New(conn, conn)
if err != nil {
t.Fatalf("auth store: %v", err)
}
cfg := &config.Config{}
cfg.Features.Auth = true
svc, err := auth.NewService(context.Background(), auth.Deps{Cfg: cfg, Store: store, Users: users})
if err != nil {
t.Fatalf("auth service: %v", err)
}
_, token, err := svc.Setup(context.Background(), "admin@example.test", "adminpass123", "Admin", "", "")
if err != nil {
t.Fatalf("setup auth session: %v", err)
}
if err := conn.Close(); err != nil {
t.Fatalf("close database: %v", err)
}
tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{{
ID: "task", OwnerPackage: "internal/task", RequiredTables: []string{"tasks"},
}})
if err != nil {
t.Fatalf("NewTracker: %v", err)
}
if err := tracker.RecordSuccess("task"); err != nil {
t.Fatalf("RecordSuccess: %v", err)
}
if err := tracker.RecordProbe("task", errTestPersistence); err != nil {
t.Fatalf("RecordProbe: %v", err)
}
router := gin.New()
router.Use(requiredPersistenceMiddleware(requiredstores.NewHealth(tracker, nil, nil)))
router.Use(authhttpmw.Middleware(svc))
router.GET("/api/v1/tasks", func(c *gin.Context) { c.Status(http.StatusOK) })
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil)
req.AddCookie(&http.Cookie{Name: svc.CookieName(), Value: token})
router.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d (not authentication failure)", rec.Code, http.StatusServiceUnavailable)
}
}