diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a5ad93a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + pull_request: + +jobs: + backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.23' + - name: Go vet + working-directory: backend + run: go vet ./... + - name: Go test + working-directory: backend + run: go test ./... -count=1 -v + + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + - name: Install deps + working-directory: frontend + run: npm ci + - name: Run tests + working-directory: frontend + run: npm run test -- --run + - name: Build + working-directory: frontend + run: npm run build + + docker: + runs-on: ubuntu-latest + needs: [backend, frontend] + steps: + - uses: actions/checkout@v4 + - name: Build Docker image + run: docker build -f docker/Dockerfile -t traefik-gui:ci . diff --git a/backend/cmd/traefik-gui/main.go b/backend/cmd/traefik-gui/main.go index 2f56374..55ebe21 100644 --- a/backend/cmd/traefik-gui/main.go +++ b/backend/cmd/traefik-gui/main.go @@ -11,6 +11,7 @@ import ( "time" "github.com/traefik/traefik-gui/backend/internal/api" + "github.com/traefik/traefik-gui/backend/internal/auth" "github.com/traefik/traefik-gui/backend/internal/config" "github.com/traefik/traefik-gui/backend/internal/database" "github.com/traefik/traefik-gui/backend/internal/traefik" @@ -76,13 +77,29 @@ func main() { log.Fatalf("Failed to configure admin password: %v", err) } - traefikClient := traefik.NewMockClient() + var traefikAPI traefik.TraefikAPI + // Try to create real Traefik client if URL is provided and not in dev mock mode + // For now, dev mode uses mock; production can set TRAEFIK_API_URL to real Traefik + if cfg.TraefikAPIURL != "" && !cfg.DevMode { + if client, err := traefik.NewClient(cfg.TraefikAPIURL); err == nil { + traefikAPI = client + log.Println("Using real Traefik API client at", cfg.TraefikAPIURL) + } else { + log.Printf("Failed to create Traefik client for %q: %v, falling back to mock", cfg.TraefikAPIURL, err) + traefikAPI = traefik.NewMockAPIClient() + } + } else { + traefikAPI = traefik.NewMockAPIClient() + } - server := api.NewServer(cfg, db, traefikClient) + server := api.NewServer(cfg, db, traefikAPI) ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Start session cleanup background job (uses auth.SessionCleanupInterval) + db.StartSessionCleanup(ctx, auth.SessionCleanupInterval) + go func() { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) diff --git a/backend/go.mod b/backend/go.mod index a0492e3..c7ac726 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,6 +6,7 @@ require ( github.com/gin-gonic/gin v1.10.0 github.com/google/uuid v1.6.0 github.com/mattn/go-sqlite3 v1.14.22 + github.com/pmezard/go-difflib v1.0.0 github.com/rs/zerolog v1.32.0 golang.org/x/crypto v0.23.0 gopkg.in/yaml.v3 v3.0.1 @@ -24,6 +25,7 @@ require ( github.com/goccy/go-json v0.10.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/kr/pretty v0.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -37,4 +39,5 @@ require ( golang.org/x/sys v0.20.0 // indirect golang.org/x/text v0.15.0 // indirect google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index ce77fcf..13c9bb9 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -38,6 +38,11 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02 github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -96,8 +101,9 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IV golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/api/handlers/auth.go b/backend/internal/api/handlers/auth.go index a819a16..3aa37e7 100644 --- a/backend/internal/api/handlers/auth.go +++ b/backend/internal/api/handlers/auth.go @@ -37,6 +37,13 @@ func NewAuthHandler( } func (h *AuthHandler) Login(c *gin.Context) { + // Rate limiting per IP + clientIP := c.ClientIP() + if !auth.DefaultLoginLimiter.Allow(clientIP) { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many login attempts, try again later"}) + return + } + var req models.LoginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) @@ -45,14 +52,17 @@ func (h *AuthHandler) Login(c *gin.Context) { user, err := h.userRepo.GetByUsername(req.Username) if err != nil || user == nil { + auth.DefaultLoginLimiter.RecordFailure(clientIP) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) return } if !auth.CheckPassword(req.Password, user.PasswordHash) { + auth.DefaultLoginLimiter.RecordFailure(clientIP) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) return } + auth.DefaultLoginLimiter.RecordSuccess(clientIP) sessionData, err := auth.NewSessionData(user.ID) if err != nil { @@ -60,17 +70,20 @@ func (h *AuthHandler) Login(c *gin.Context) { return } + sessionID, err := auth.GenerateSessionID() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session"}) + return + } + session := &models.Session{ - ID: sessionData.CreatedAt.Format("20060102150405") + "-" + sessionData.CSRFToken[:8], + ID: sessionID, UserID: user.ID, CSRFToken: sessionData.CSRFToken, CreatedAt: sessionData.CreatedAt, ExpiresAt: sessionData.ExpiresAt, } - // Use a proper UUID for session ID - session.ID, _ = auth.GenerateSessionID() - if err := h.sessionRepo.Create(session); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save session"}) return @@ -109,13 +122,32 @@ func (h *AuthHandler) Me(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) return } - - c.JSON(http.StatusOK, gin.H{ + // Include current CSRF token for bootstrap after page reload + var csrfToken string + if sVal, exists := c.Get("session"); exists { + if s, ok := sVal.(*models.Session); ok && s != nil { + csrfToken = s.CSRFToken + } + } + resp := gin.H{ "id": user.ID, "username": user.Username, "email": user.Email, "role": user.Role, - }) + } + if csrfToken != "" { + resp["csrf_token"] = csrfToken + } + c.JSON(http.StatusOK, resp) +} + +func (h *AuthHandler) GetCSRF(c *gin.Context) { + val, _ := c.Get("session"); sess, _ := val.(*models.Session) + if sess == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) + return + } + c.JSON(http.StatusOK, gin.H{"csrf_token": sess.CSRFToken}) } func (h *AuthHandler) setSessionCookie(c *gin.Context, sessionID string, expiresAt time.Time) { diff --git a/backend/internal/api/handlers/config_file.go b/backend/internal/api/handlers/config_file.go index 0e917fe..9fc15d6 100644 --- a/backend/internal/api/handlers/config_file.go +++ b/backend/internal/api/handlers/config_file.go @@ -44,6 +44,15 @@ type PreviewRequest struct { } func (h *FileConfigHandler) Preview(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + if user.Role != "admin" && user.Role != "operator" { + c.JSON(http.StatusForbidden, gin.H{"error": "operator or admin required"}) + return + } var req PreviewRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "filename and content required"}) @@ -136,6 +145,15 @@ func (h *FileConfigHandler) History(c *gin.Context) { } func (h *FileConfigHandler) Validate(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + if user.Role != "admin" && user.Role != "operator" { + c.JSON(http.StatusForbidden, gin.H{"error": "operator or admin required"}) + return + } var req PreviewRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "filename and content required"}) diff --git a/backend/internal/api/handlers/handlers_rbac_test.go b/backend/internal/api/handlers/handlers_rbac_test.go new file mode 100644 index 0000000..97dd277 --- /dev/null +++ b/backend/internal/api/handlers/handlers_rbac_test.go @@ -0,0 +1,425 @@ +package handlers + +import ( + "bytes" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + "github.com/traefik/traefik-gui/backend/internal/api/middleware" + "github.com/traefik/traefik-gui/backend/internal/auth" + "github.com/traefik/traefik-gui/backend/internal/config/file" + "github.com/traefik/traefik-gui/backend/internal/database/repositories" + "github.com/traefik/traefik-gui/backend/internal/models" + _ "github.com/mattn/go-sqlite3" +) + +func setupTestDB(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=on") + if err != nil { + t.Fatalf("open db: %v", err) + } + // create tables + for _, q := range []string{ + `CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'viewer', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_login DATETIME)`, + `CREATE TABLE sessions (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, csrf_token TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME NOT NULL, CHECK (expires_at > created_at))`, + `CREATE TABLE backups (id TEXT PRIMARY KEY, filename TEXT NOT NULL, content TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by TEXT NOT NULL, reason TEXT NOT NULL)`, + `CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`, + } { + if _, err := db.Exec(q); err != nil { + t.Fatalf("create table: %v", err) + } + } + return db +} + +func createUser(t *testing.T, db *sql.DB, id, username, role, password string) *models.User { + t.Helper() + hash, err := auth.HashPassword(password) + if err != nil { + t.Fatalf("hash: %v", err) + } + _, err = db.Exec(`INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, ?, ?)`, id, username, username+"@test.local", hash, role) + if err != nil { + t.Fatalf("insert user: %v", err) + } + return &models.User{ID: id, Username: username, Email: username + "@test.local", Role: role, PasswordHash: hash} +} + +func createSession(t *testing.T, db *sql.DB, userID string) *models.Session { + t.Helper() + sd, err := auth.NewSessionData(userID) + if err != nil { + t.Fatalf("new session: %v", err) + } + id, err := auth.GenerateSessionID() + if err != nil { + t.Fatalf("gen id: %v", err) + } + sess := &models.Session{ID: id, UserID: userID, CSRFToken: sd.CSRFToken, CreatedAt: sd.CreatedAt, ExpiresAt: sd.ExpiresAt} + repo := repositories.NewSessionRepository(db) + if err := repo.Create(sess); err != nil { + t.Fatalf("create session: %v", err) + } + return sess +} + +func newTestRouter(t *testing.T, db *sql.DB) (*gin.Engine, *file.Service) { + t.Helper() + gin.SetMode(gin.TestMode) + dir, err := os.MkdirTemp("", "cfg-*") + if err != nil { + t.Fatalf("tmp dir: %v", err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + // also ensure backups dir is created by service; we use filepath join + svc, err := file.NewService(filepath.Join(dir, "dynamic"), db) + if err != nil { + t.Fatalf("service: %v", err) + } + userRepo := repositories.NewUserRepository(db) + sessionRepo := repositories.NewSessionRepository(db) + authMw := middleware.NewAuthMiddleware(sessionRepo, userRepo) + authHandler := NewAuthHandler(userRepo, sessionRepo, "test-secret-32-chars-minimum-length", "", false) + fileHandler := NewFileConfigHandler(svc) + + r := gin.New() + r.Use(middleware.CORSMiddleware("http://localhost:5173")) + // auth routes + authGroup := r.Group("/api/auth") + { + authGroup.POST("/login", authHandler.Login) + authGroup.POST("/logout", authMw.RequireAuth(), authMw.RequireCSRF(), authHandler.Logout) + authGroup.GET("/me", authMw.RequireAuth(), authHandler.Me) + authGroup.GET("/csrf", authMw.RequireAuth(), authHandler.GetCSRF) + } + apiGroup := r.Group("/api") + apiGroup.Use(authMw.RequireAuth()) + apiGroup.Use(authMw.RequireCSRF()) + { + cfg := apiGroup.Group("/config") + { + cfg.GET("/files", fileHandler.ListFiles) + cfg.GET("/files/:name", fileHandler.GetFile) + cfg.GET("/history", fileHandler.History) + cfg.POST("/preview", fileHandler.Preview) + cfg.POST("/validate", fileHandler.Validate) + cfg.POST("/apply", fileHandler.Apply) + cfg.POST("/rollback", fileHandler.Rollback) + } + } + return r, svc +} + +func doRequest(r *gin.Engine, method, path string, body interface{}, cookies []*http.Cookie, csrf string) *httptest.ResponseRecorder { + var buf bytes.Buffer + if body != nil { + json.NewEncoder(&buf).Encode(body) + } + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", "application/json") + if csrf != "" { + req.Header.Set("X-CSRF-Token", csrf) + } + for _, c := range cookies { + req.AddCookie(c) + } + // Set origin for CORS + req.Header.Set("Origin", "http://localhost:5173") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +const validYAML = `http: + routers: + r1: + rule: "Host(` + "`a.com`" + `)" + service: s1 + services: + s1: + loadBalancer: + servers: + - url: "http://127.0.0.1:8080" +` + +func TestRBAC_ViewerCannotPreviewValidateApplyRollback(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + r, _ := newTestRouter(t, db) + viewer := createUser(t, db, "viewer-id", "viewer", "viewer", "viewerpass123") + sess := createSession(t, db, viewer.ID) + cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"} + + tests := []struct { + path string + body interface{} + }{ + {"/api/config/preview", map[string]interface{}{"filename": "app.yml", "content": validYAML}}, + {"/api/config/validate", map[string]interface{}{"filename": "app.yml", "content": validYAML}}, + {"/api/config/apply", map[string]interface{}{"filename": "app.yml", "content": validYAML, "confirm": true}}, + {"/api/config/rollback", map[string]interface{}{"filename": "app.yml"}}, + } + for _, tc := range tests { + w := doRequest(r, "POST", tc.path, tc.body, []*http.Cookie{cookie}, sess.CSRFToken) + if w.Code != http.StatusForbidden { + t.Errorf("viewer %s expected 403, got %d body %s", tc.path, w.Code, w.Body.String()) + } + // Rotate happened even on forbidden? Should not rotate on forbidden, check not needed + } +} + +func TestRBAC_OperatorCanPreviewValidateApplyButNotRollback(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + r, _ := newTestRouter(t, db) + op := createUser(t, db, "op-id", "operator", "operator", "operatorpass123") + sess := createSession(t, db, op.ID) + cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"} + + // preview should succeed (valid) + w := doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app.yml", "content": validYAML}, []*http.Cookie{cookie}, sess.CSRFToken) + if w.Code != 200 { + t.Fatalf("operator preview expected 200, got %d %s", w.Code, w.Body.String()) + } + // need to fetch rotated token from header for next request + newCSRF := w.Header().Get("X-CSRF-Token") + if newCSRF == "" { + newCSRF = sess.CSRFToken // fallback if not rotated (but should be) + } + // update sess token for subsequent requests - fetch from DB + repo := repositories.NewSessionRepository(db) + updated, _ := repo.GetByID(sess.ID) + if updated != nil { + newCSRF = updated.CSRFToken + } + + w = doRequest(r, "POST", "/api/config/validate", map[string]interface{}{"filename": "app.yml", "content": validYAML}, []*http.Cookie{cookie}, newCSRF) + if w.Code != 200 { + t.Fatalf("operator validate expected 200, got %d %s", w.Code, w.Body.String()) + } + updated, _ = repo.GetByID(sess.ID) + newCSRF = updated.CSRFToken + + w = doRequest(r, "POST", "/api/config/apply", map[string]interface{}{"filename": "app.yml", "content": validYAML, "confirm": true}, []*http.Cookie{cookie}, newCSRF) + if w.Code != 200 { + t.Fatalf("operator apply expected 200, got %d %s", w.Code, w.Body.String()) + } + updated, _ = repo.GetByID(sess.ID) + newCSRF = updated.CSRFToken + + w = doRequest(r, "POST", "/api/config/rollback", map[string]interface{}{"filename": "app.yml"}, []*http.Cookie{cookie}, newCSRF) + if w.Code != http.StatusForbidden { + t.Fatalf("operator rollback expected 403, got %d %s", w.Code, w.Body.String()) + } +} + +func TestRBAC_AdminCanAll(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + r, _ := newTestRouter(t, db) + admin := createUser(t, db, "admin-id", "admin", "admin", "adminpass12345") + sess := createSession(t, db, admin.ID) + cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"} + repo := repositories.NewSessionRepository(db) + + csrf := sess.CSRFToken + for _, tc := range []struct{ path string; body interface{} }{ + {"/api/config/preview", map[string]interface{}{"filename": "app.yml", "content": validYAML}}, + {"/api/config/validate", map[string]interface{}{"filename": "app.yml", "content": validYAML}}, + } { + w := doRequest(r, "POST", tc.path, tc.body, []*http.Cookie{cookie}, csrf) + if w.Code != 200 { + t.Fatalf("admin %s expected 200, got %d %s", tc.path, w.Code, w.Body.String()) + } + updated, _ := repo.GetByID(sess.ID) + csrf = updated.CSRFToken + } + // apply + w := doRequest(r, "POST", "/api/config/apply", map[string]interface{}{"filename": "app.yml", "content": validYAML, "confirm": true}, []*http.Cookie{cookie}, csrf) + if w.Code != 200 { + t.Fatalf("admin apply 200 got %d %s", w.Code, w.Body.String()) + } + updated, _ := repo.GetByID(sess.ID) + csrf = updated.CSRFToken + w = doRequest(r, "POST", "/api/config/rollback", map[string]interface{}{"filename": "app.yml"}, []*http.Cookie{cookie}, csrf) + if w.Code != 200 { + t.Fatalf("admin rollback 200 got %d %s", w.Code, w.Body.String()) + } +} + +func TestCSRF_MissingOrInvalidFails(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + r, _ := newTestRouter(t, db) + admin := createUser(t, db, "admin2", "admin2", "admin", "adminpass12345") + sess := createSession(t, db, admin.ID) + cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"} + + // missing token + w := doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app.yml", "content": validYAML}, []*http.Cookie{cookie}, "") + if w.Code != http.StatusForbidden { + t.Fatalf("missing CSRF should be 403, got %d", w.Code) + } + // invalid token + w = doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app.yml", "content": validYAML}, []*http.Cookie{cookie}, "bad-token") + if w.Code != http.StatusForbidden { + t.Fatalf("invalid CSRF should be 403, got %d", w.Code) + } +} + +func TestCSRF_Rotation(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + r, _ := newTestRouter(t, db) + admin := createUser(t, db, "admin3", "admin3", "admin", "adminpass12345") + sess := createSession(t, db, admin.ID) + cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"} + oldToken := sess.CSRFToken + + w := doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app.yml", "content": validYAML}, []*http.Cookie{cookie}, oldToken) + if w.Code != 200 { + t.Fatalf("preview failed %d", w.Code) + } + newToken := w.Header().Get("X-CSRF-Token") + if newToken == "" { + t.Fatalf("expected rotated token in header") + } + if newToken == oldToken { + t.Fatalf("token should rotate") + } + // old token should now fail + w2 := doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app2.yml", "content": validYAML}, []*http.Cookie{cookie}, oldToken) + if w2.Code != http.StatusForbidden { + t.Fatalf("old token should be invalid after rotation, got %d", w2.Code) + } + // new token should succeed + w3 := doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app2.yml", "content": validYAML}, []*http.Cookie{cookie}, newToken) + if w3.Code != 200 { + t.Fatalf("new token should succeed, got %d", w3.Code) + } +} + +func TestLogoutRequiresCSRF(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + r, _ := newTestRouter(t, db) + admin := createUser(t, db, "admin4", "admin4", "admin", "adminpass12345") + sess := createSession(t, db, admin.ID) + cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"} + + // without CSRF + w := doRequest(r, "POST", "/api/auth/logout", nil, []*http.Cookie{cookie}, "") + if w.Code != http.StatusForbidden { + t.Fatalf("logout without CSRF should be 403, got %d %s", w.Code, w.Body.String()) + } + // with invalid + w = doRequest(r, "POST", "/api/auth/logout", nil, []*http.Cookie{cookie}, "bad") + if w.Code != http.StatusForbidden { + t.Fatalf("logout invalid CSRF 403, got %d", w.Code) + } + // with valid should succeed + w = doRequest(r, "POST", "/api/auth/logout", nil, []*http.Cookie{cookie}, sess.CSRFToken) + if w.Code != 200 { + t.Fatalf("logout valid should be 200, got %d %s", w.Code, w.Body.String()) + } +} + +func TestAuthMe_ReturnsCSRF(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + r, _ := newTestRouter(t, db) + admin := createUser(t, db, "admin5", "admin5", "admin", "adminpass12345") + sess := createSession(t, db, admin.ID) + cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"} + + // GET /me should return csrf_token + w := doRequest(r, "GET", "/api/auth/me", nil, []*http.Cookie{cookie}, "") + if w.Code != 200 { + t.Fatalf("me expected 200, got %d", w.Code) + } + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode me: %v", err) + } + if resp["csrf_token"] == nil || resp["csrf_token"] == "" { + t.Fatalf("expected csrf_token in me response, got %v", resp) + } + if resp["csrf_token"] != sess.CSRFToken { + t.Fatalf("csrf_token mismatch expected %q got %q", sess.CSRFToken, resp["csrf_token"]) + } + // Also test /csrf endpoint + w = doRequest(r, "GET", "/api/auth/csrf", nil, []*http.Cookie{cookie}, "") + if w.Code != 200 { + t.Fatalf("csrf endpoint 200 got %d", w.Code) + } + var csrfResp map[string]string + json.NewDecoder(w.Body).Decode(&csrfResp) + if csrfResp["csrf_token"] != sess.CSRFToken { + t.Fatalf("csrf endpoint mismatch") + } +} + +func TestLoginAndPostWithCSRF(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + r, _ := newTestRouter(t, db) + _ = createUser(t, db, "login-user", "loginuser", "admin", "securepass12345") + // perform login + w := doRequest(r, "POST", "/api/auth/login", map[string]string{"username": "loginuser", "password": "securepass12345"}, nil, "") + if w.Code != 200 { + t.Fatalf("login failed %d %s", w.Code, w.Body.String()) + } + var loginResp map[string]interface{} + json.NewDecoder(w.Body).Decode(&loginResp) + csrf, _ := loginResp["csrf_token"].(string) + if csrf == "" { + t.Fatalf("login should return csrf_token") + } + // extract cookie + var sessCookie *http.Cookie + for _, c := range w.Result().Cookies() { + if c.Name == "traefik_gui_session" { + sessCookie = c + break + } + } + if sessCookie == nil { + t.Fatalf("no session cookie") + } + // POST preview with csrf from login + w = doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app.yml", "content": validYAML}, []*http.Cookie{sessCookie}, csrf) + if w.Code != 200 { + t.Fatalf("post after login should succeed, got %d %s", w.Code, w.Body.String()) + } + // Simulate reload: GET /me to get csrf + w = doRequest(r, "GET", "/api/auth/me", nil, []*http.Cookie{sessCookie}, "") + if w.Code != 200 { + t.Fatalf("reload me failed %d", w.Code) + } + var me map[string]interface{} + json.NewDecoder(w.Body).Decode(&me) + csrf2, _ := me["csrf_token"].(string) + if csrf2 == "" { + t.Fatalf("me should return csrf") + } + // Use csrf2 for next POST (might be same as rotated? Need to fetch updated token after previous POST rotation) + // The preview POST rotated token, so csrf2 should be the rotated one + // Actually we already fetched the rotated token via header, but /me should return current + // Get repository current token + repo := repositories.NewSessionRepository(db) + sess, _ := repo.GetByID(sessCookie.Value) + if sess.CSRFToken != csrf2 { + t.Fatalf("me csrf should match DB %q vs %q", sess.CSRFToken, csrf2) + } + w = doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app2.yml", "content": validYAML}, []*http.Cookie{sessCookie}, csrf2) + if w.Code != 200 { + t.Fatalf("post after reload should succeed, got %d %s", w.Code, w.Body.String()) + } +} diff --git a/backend/internal/api/handlers/traefik_api.go b/backend/internal/api/handlers/traefik_api.go new file mode 100644 index 0000000..31795ef --- /dev/null +++ b/backend/internal/api/handlers/traefik_api.go @@ -0,0 +1,190 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "sort" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/traefik/traefik-gui/backend/internal/api/middleware" + "github.com/traefik/traefik-gui/backend/internal/traefik" +) + +// TraefikAPIHandler handles /api/traefik/* routes via the typed Traefik client +type TraefikAPIHandler struct { + api traefik.TraefikAPI + cache map[string]cacheEntry + mu sync.RWMutex + ttl time.Duration +} + +type cacheEntry struct { + body []byte + status int + expiry time.Time +} + +// NewTraefikAPIHandler creates a handler with 15s cache +func NewTraefikAPIHandler(api traefik.TraefikAPI) *TraefikAPIHandler { + return &TraefikAPIHandler{ + api: api, + cache: make(map[string]cacheEntry), + ttl: 15 * time.Second, + } +} + +// for tests to inject custom ttl or clear cache +func (h *TraefikAPIHandler) clearCache() { + h.mu.Lock() + defer h.mu.Unlock() + h.cache = make(map[string]cacheEntry) +} + +func (h *TraefikAPIHandler) cached(c *gin.Context, path string, fn func() (interface{}, error)) { + // role check: viewer+ for reads + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + if user.Role != "viewer" && user.Role != "operator" && user.Role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"}) + return + } + + // ?refresh=1 bypasses cache + if c.Query("refresh") == "1" { + h.mu.Lock() + delete(h.cache, path) + h.mu.Unlock() + } else { + h.mu.RLock() + if e, ok := h.cache[path]; ok && time.Now().Before(e.expiry) { + h.mu.RUnlock() + c.Data(e.status, "application/json", e.body) + return + } + h.mu.RUnlock() + } + + data, err := fn() + if err != nil { + if traefik.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found", "detail": err.Error()}) + return + } + if traefik.IsUnauthorized(err) { + // 401 from Traefik is upstream problem, not caller's auth failure -> 502 + c.JSON(http.StatusBadGateway, gin.H{"error": "traefik API unauthorized - check if Traefik API is enabled or on a private entrypoint", "detail": err.Error()}) + return + } + if apiErr, ok := err.(*traefik.APIError); ok { + // Preserve upstream status for 5xx, else 502 + status := apiErr.StatusCode + if status < 400 || status >= 600 { + status = http.StatusBadGateway + } + c.JSON(status, gin.H{"error": apiErr.Message, "detail": apiErr.Body}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Marshal to cache + // Use gin to marshal via c.JSON would not give us bytes for cache; we mimic JSON marshal + // Instead we use c.JSON and also cache the body by re-marshaling + // Simplify: use c.JSON and store via recording? For now marshal manually + // We'll just call c.JSON and also store the marshaled bytes via helper + // To avoid double marshal, we directly marshal and cache + // Use gin's JSON rendering via helper + body, err := marshalJSON(data) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "marshal error"}) + return + } + h.mu.Lock() + h.cache[path] = cacheEntry{body: body, status: http.StatusOK, expiry: time.Now().Add(h.ttl)} + h.mu.Unlock() + c.Data(http.StatusOK, "application/json", body) +} + +func marshalJSON(v interface{}) ([]byte, error) { + return json.Marshal(v) +} + +func (h *TraefikAPIHandler) Health(c *gin.Context) { + h.cached(c, c.Request.URL.Path, func() (interface{}, error) { + raw, err := h.api.GetHealthz(c.Request.Context()) + if err != nil { + return nil, err + } + return gin.H{"healthy": true, "raw": raw}, nil + }) +} + +func (h *TraefikAPIHandler) Overview(c *gin.Context) { + h.cached(c, c.Request.URL.Path, func() (interface{}, error) { + return h.api.GetOverview(c.Request.Context()) + }) +} + +func (h *TraefikAPIHandler) Entrypoints(c *gin.Context) { + h.cached(c, c.Request.URL.Path, func() (interface{}, error) { + return h.api.GetEntrypoints(c.Request.Context()) + }) +} + +func (h *TraefikAPIHandler) Routers(c *gin.Context) { + h.cached(c, c.Request.URL.Path, func() (interface{}, error) { + return h.api.GetRouters(c.Request.Context()) + }) +} + +func (h *TraefikAPIHandler) Services(c *gin.Context) { + h.cached(c, c.Request.URL.Path, func() (interface{}, error) { + return h.api.GetServices(c.Request.Context()) + }) +} + +func (h *TraefikAPIHandler) Middlewares(c *gin.Context) { + h.cached(c, c.Request.URL.Path, func() (interface{}, error) { + return h.api.GetMiddlewares(c.Request.Context()) + }) +} + +func (h *TraefikAPIHandler) Providers(c *gin.Context) { + h.cached(c, c.Request.URL.Path, func() (interface{}, error) { + return h.api.GetProviders(c.Request.Context()) + }) +} + +func (h *TraefikAPIHandler) Certificates(c *gin.Context) { + h.cached(c, c.Request.URL.Path, func() (interface{}, error) { + certs, err := h.api.GetCertificates(c.Request.Context()) + if err != nil { + return nil, err + } + // Sort ascending by notAfter + sort.Slice(certs, func(i, j int) bool { + return certs[i].NotAfter.Before(certs[j].NotAfter) + }) + now := time.Now() + type certWithExpiry struct { + traefik.Certificate + DaysUntilExpiry float64 `json:"days_until_expiry"` + Expired bool `json:"expired"` + } + out := make([]certWithExpiry, len(certs)) + for i, cert := range certs { + out[i] = certWithExpiry{ + Certificate: cert, + DaysUntilExpiry: cert.NotAfter.Sub(now).Hours() / 24.0, + Expired: cert.NotAfter.Before(now), + } + } + return out, nil + }) +} diff --git a/backend/internal/api/handlers/traefik_api_test.go b/backend/internal/api/handlers/traefik_api_test.go new file mode 100644 index 0000000..e7eb0be --- /dev/null +++ b/backend/internal/api/handlers/traefik_api_test.go @@ -0,0 +1,434 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/traefik/traefik-gui/backend/internal/api/middleware" + "github.com/traefik/traefik-gui/backend/internal/database/repositories" + "github.com/traefik/traefik-gui/backend/internal/traefik" +) + +// mockAPI implements traefik.TraefikAPI for handler tests +type mockAPI struct { + healthz func() (*traefik.HealthResponse, error) + overview func() (*traefik.Overview, error) + entrypoints func() ([]traefik.Entrypoint, error) + routers func() ([]traefik.Router, error) + services func() ([]traefik.Service, error) + middlewares func() ([]traefik.Middleware, error) + providers func() ([]traefik.Provider, error) + certificates func() ([]traefik.Certificate, error) + calls map[string]*int32 +} + +func newMockAPI() *mockAPI { + return &mockAPI{ + calls: make(map[string]*int32), + } +} + +func (m *mockAPI) inc(key string) { + if _, ok := m.calls[key]; !ok { + var v int32 + m.calls[key] = &v + } + atomic.AddInt32(m.calls[key], 1) +} +func (m *mockAPI) count(key string) int { + if v, ok := m.calls[key]; ok { + return int(atomic.LoadInt32(v)) + } + return 0 +} + +func (m *mockAPI) GetHealthz(ctx context.Context) (*traefik.HealthResponse, error) { + m.inc("healthz") + if m.healthz != nil { + return m.healthz() + } + return &traefik.HealthResponse{Status: "OK"}, nil +} +func (m *mockAPI) GetOverview(ctx context.Context) (*traefik.Overview, error) { + m.inc("overview") + if m.overview != nil { + return m.overview() + } + return &traefik.Overview{TotalRouters: 1, TraefikVersion: "3.7.0"}, nil +} +func (m *mockAPI) GetEntrypoints(ctx context.Context) ([]traefik.Entrypoint, error) { + m.inc("entrypoints") + if m.entrypoints != nil { + return m.entrypoints() + } + return []traefik.Entrypoint{{Name: "web", Address: ":80"}}, nil +} +func (m *mockAPI) GetRouters(ctx context.Context) ([]traefik.Router, error) { + m.inc("routers") + if m.routers != nil { + return m.routers() + } + return []traefik.Router{{Name: "r1@docker", Provider: "docker"}}, nil +} +func (m *mockAPI) GetServices(ctx context.Context) ([]traefik.Service, error) { + m.inc("services") + if m.services != nil { + return m.services() + } + return []traefik.Service{{Name: "s1@docker", Provider: "docker"}}, nil +} +func (m *mockAPI) GetMiddlewares(ctx context.Context) ([]traefik.Middleware, error) { + m.inc("middlewares") + if m.middlewares != nil { + return m.middlewares() + } + return []traefik.Middleware{{Name: "m1@docker", Provider: "docker"}}, nil +} +func (m *mockAPI) GetProviders(ctx context.Context) ([]traefik.Provider, error) { + m.inc("providers") + if m.providers != nil { + return m.providers() + } + return []traefik.Provider{{Name: "docker"}}, nil +} +func (m *mockAPI) GetCertificates(ctx context.Context) ([]traefik.Certificate, error) { + m.inc("certificates") + if m.certificates != nil { + return m.certificates() + } + now := time.Now() + return []traefik.Certificate{ + {Store: "default", Names: []string{"a.com"}, NotAfter: now.Add(48 * time.Hour), Issuer: "CA", SANs: []string{"a.com"}}, + {Store: "default", Names: []string{"b.com"}, NotAfter: now.Add(24 * time.Hour), Issuer: "CA", SANs: []string{"b.com"}}, + }, nil +} + +// helper to setup router with auth and return admin cookie +func setupTraefikHandlerTest(t *testing.T, api traefik.TraefikAPI) (*gin.Engine, *TraefikAPIHandler, *http.Cookie) { + t.Helper() + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + // Keep db open for duration of test; t.Cleanup will close when test ends? setupTestDB doesn't close, we need to defer close + t.Cleanup(func() { db.Close() }) + admin := createUser(t, db, "admin-id", "admin", "admin", "adminpass12345") + sess := createSession(t, db, admin.ID) + // also create viewer for role tests + viewer := createUser(t, db, "viewer-id", "viewer", "viewer", "viewerpass12345") + _ = createSession(t, db, viewer.ID) + + userRepo := repositories.NewUserRepository(db) + sessionRepo := repositories.NewSessionRepository(db) + authMw := middleware.NewAuthMiddleware(sessionRepo, userRepo) + handler := NewTraefikAPIHandler(api) + + r := gin.New() + r.Use(middleware.CORSMiddleware("http://localhost:5173")) + g := r.Group("/api/traefik") + g.Use(authMw.RequireAuth()) + { + g.GET("/health", handler.Health) + g.GET("/overview", handler.Overview) + g.GET("/entrypoints", handler.Entrypoints) + g.GET("/routers", handler.Routers) + g.GET("/services", handler.Services) + g.GET("/middlewares", handler.Middlewares) + g.GET("/providers", handler.Providers) + g.GET("/certificates", handler.Certificates) + } + cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"} + return r, handler, cookie +} + +func doTraefikRequest(r *gin.Engine, method, path string, cookie *http.Cookie) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, nil) + req.Header.Set("Origin", "http://localhost:5173") + if cookie != nil { + req.AddCookie(cookie) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func TestTraefikHandler_Health_200(t *testing.T) { + api := newMockAPI() + r, _, cookie := setupTraefikHandlerTest(t, api) + w := doTraefikRequest(r, "GET", "/api/traefik/health", cookie) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp["healthy"] != true { + t.Fatalf("expected healthy true, got %v", resp["healthy"]) + } +} + +func TestTraefikHandler_Overview_200(t *testing.T) { + api := newMockAPI() + api.overview = func() (*traefik.Overview, error) { + return &traefik.Overview{TotalRouters: 5, TotalServices: 3, TotalMiddlewares: 2, TraefikVersion: "3.7.0", TraefikCodename: "lascaux", Providers: []string{"docker"}}, nil + } + r, _, cookie := setupTraefikHandlerTest(t, api) + w := doTraefikRequest(r, "GET", "/api/traefik/overview", cookie) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d %s", w.Code, w.Body.String()) + } + var out traefik.Overview + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v", err) + } + if out.TotalRouters != 5 || out.TraefikVersion != "3.7.0" { + t.Fatalf("unexpected overview: %#v", out) + } +} + +func TestTraefikHandler_EachRoute_200(t *testing.T) { + api := newMockAPI() + r, _, cookie := setupTraefikHandlerTest(t, api) + paths := []string{"/api/traefik/entrypoints", "/api/traefik/routers", "/api/traefik/services", "/api/traefik/middlewares", "/api/traefik/providers", "/api/traefik/certificates"} + for _, p := range paths { + w := doTraefikRequest(r, "GET", p, cookie) + if w.Code != http.StatusOK { + t.Fatalf("path %s expected 200, got %d %s", p, w.Code, w.Body.String()) + } + } +} + +func TestTraefikHandler_Upstream404(t *testing.T) { + api := newMockAPI() + api.routers = func() ([]traefik.Router, error) { + return nil, &traefik.APIError{StatusCode: 404, Message: "not found", URL: "http://traefik/api/routers"} + } + r, _, cookie := setupTraefikHandlerTest(t, api) + w := doTraefikRequest(r, "GET", "/api/traefik/routers", cookie) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d %s", w.Code, w.Body.String()) + } +} + +func TestTraefikHandler_Upstream500(t *testing.T) { + api := newMockAPI() + api.services = func() ([]traefik.Service, error) { + return nil, &traefik.APIError{StatusCode: 500, Message: "internal", URL: "http://traefik/api/services"} + } + r, _, cookie := setupTraefikHandlerTest(t, api) + w := doTraefikRequest(r, "GET", "/api/traefik/services", cookie) + if w.Code != 500 { + t.Fatalf("expected 500, got %d %s", w.Code, w.Body.String()) + } +} + +func TestTraefikHandler_UpstreamUnauthorizedMapsTo502(t *testing.T) { + api := newMockAPI() + api.middlewares = func() ([]traefik.Middleware, error) { + return nil, &traefik.APIError{StatusCode: 401, Message: "unauthorized", URL: "http://traefik/api/middlewares"} + } + r, _, cookie := setupTraefikHandlerTest(t, api) + w := doTraefikRequest(r, "GET", "/api/traefik/middlewares", cookie) + if w.Code != http.StatusBadGateway { + t.Fatalf("expected 502, got %d %s", w.Code, w.Body.String()) + } + if !contains(w.Body.String(), "private entrypoint") { + t.Fatalf("expected private entrypoint message, got %s", w.Body.String()) + } + // also test 403 + api.middlewares = func() ([]traefik.Middleware, error) { + return nil, &traefik.APIError{StatusCode: 403, Message: "forbidden", URL: "http://traefik/api/middlewares"} + } + // need new handler to clear cache (previous 502 cached? Our cache caches only success, not errors, so fine) + w = doTraefikRequest(r, "GET", "/api/traefik/middlewares", cookie) + if w.Code != http.StatusBadGateway { + t.Fatalf("403 should also map to 502, got %d", w.Code) + } +} + +func TestTraefikHandler_EachRoute_Upstream404_500_Unauthorized(t *testing.T) { + // For each of the 8 routes, test 404, 500, 401->502 + routes := []struct { + path string + setup404 func(*mockAPI) + setup500 func(*mockAPI) + setup401 func(*mockAPI) + }{ + {"/api/traefik/health", func(m *mockAPI) { m.healthz = func() (*traefik.HealthResponse, error) { return nil, &traefik.APIError{StatusCode: 404, Message: "not found", URL: ""} } }, func(m *mockAPI) { m.healthz = func() (*traefik.HealthResponse, error) { return nil, &traefik.APIError{StatusCode: 500, Message: "err", URL: ""} } }, func(m *mockAPI) { m.healthz = func() (*traefik.HealthResponse, error) { return nil, &traefik.APIError{StatusCode: 401, Message: "unauth", URL: ""} } }}, + {"/api/traefik/overview", func(m *mockAPI) { m.overview = func() (*traefik.Overview, error) { return nil, &traefik.APIError{StatusCode: 404, Message: "not found", URL: ""} } }, func(m *mockAPI) { m.overview = func() (*traefik.Overview, error) { return nil, &traefik.APIError{StatusCode: 500, Message: "err", URL: ""} } }, func(m *mockAPI) { m.overview = func() (*traefik.Overview, error) { return nil, &traefik.APIError{StatusCode: 401, Message: "unauth", URL: ""} } }}, + {"/api/traefik/entrypoints", func(m *mockAPI) { m.entrypoints = func() ([]traefik.Entrypoint, error) { return nil, &traefik.APIError{StatusCode: 404, Message: "not found", URL: ""} } }, func(m *mockAPI) { m.entrypoints = func() ([]traefik.Entrypoint, error) { return nil, &traefik.APIError{StatusCode: 500, Message: "err", URL: ""} } }, func(m *mockAPI) { m.entrypoints = func() ([]traefik.Entrypoint, error) { return nil, &traefik.APIError{StatusCode: 401, Message: "unauth", URL: ""} } }}, + {"/api/traefik/routers", func(m *mockAPI) { m.routers = func() ([]traefik.Router, error) { return nil, &traefik.APIError{StatusCode: 404, Message: "not found", URL: ""} } }, func(m *mockAPI) { m.routers = func() ([]traefik.Router, error) { return nil, &traefik.APIError{StatusCode: 500, Message: "err", URL: ""} } }, func(m *mockAPI) { m.routers = func() ([]traefik.Router, error) { return nil, &traefik.APIError{StatusCode: 401, Message: "unauth", URL: ""} } }}, + {"/api/traefik/services", func(m *mockAPI) { m.services = func() ([]traefik.Service, error) { return nil, &traefik.APIError{StatusCode: 404, Message: "not found", URL: ""} } }, func(m *mockAPI) { m.services = func() ([]traefik.Service, error) { return nil, &traefik.APIError{StatusCode: 500, Message: "err", URL: ""} } }, func(m *mockAPI) { m.services = func() ([]traefik.Service, error) { return nil, &traefik.APIError{StatusCode: 401, Message: "unauth", URL: ""} } }}, + {"/api/traefik/middlewares", func(m *mockAPI) { m.middlewares = func() ([]traefik.Middleware, error) { return nil, &traefik.APIError{StatusCode: 404, Message: "not found", URL: ""} } }, func(m *mockAPI) { m.middlewares = func() ([]traefik.Middleware, error) { return nil, &traefik.APIError{StatusCode: 500, Message: "err", URL: ""} } }, func(m *mockAPI) { m.middlewares = func() ([]traefik.Middleware, error) { return nil, &traefik.APIError{StatusCode: 401, Message: "unauth", URL: ""} } }}, + {"/api/traefik/providers", func(m *mockAPI) { m.providers = func() ([]traefik.Provider, error) { return nil, &traefik.APIError{StatusCode: 404, Message: "not found", URL: ""} } }, func(m *mockAPI) { m.providers = func() ([]traefik.Provider, error) { return nil, &traefik.APIError{StatusCode: 500, Message: "err", URL: ""} } }, func(m *mockAPI) { m.providers = func() ([]traefik.Provider, error) { return nil, &traefik.APIError{StatusCode: 401, Message: "unauth", URL: ""} } }}, + {"/api/traefik/certificates", func(m *mockAPI) { m.certificates = func() ([]traefik.Certificate, error) { return nil, &traefik.APIError{StatusCode: 404, Message: "not found", URL: ""} } }, func(m *mockAPI) { m.certificates = func() ([]traefik.Certificate, error) { return nil, &traefik.APIError{StatusCode: 500, Message: "err", URL: ""} } }, func(m *mockAPI) { m.certificates = func() ([]traefik.Certificate, error) { return nil, &traefik.APIError{StatusCode: 401, Message: "unauth", URL: ""} } }}, + } + for _, tc := range routes { + // 404 + api := newMockAPI() + tc.setup404(api) + r, _, cookie := setupTraefikHandlerTest(t, api) + w := doTraefikRequest(r, "GET", tc.path, cookie) + if w.Code != 404 { + t.Fatalf("route %s 404 expected 404, got %d %s", tc.path, w.Code, w.Body.String()) + } + // 500 + api = newMockAPI() + tc.setup500(api) + r, _, cookie = setupTraefikHandlerTest(t, api) + w = doTraefikRequest(r, "GET", tc.path, cookie) + if w.Code != 500 { + t.Fatalf("route %s 500 expected 500, got %d %s", tc.path, w.Code, w.Body.String()) + } + // 401 -> 502 + api = newMockAPI() + tc.setup401(api) + r, _, cookie = setupTraefikHandlerTest(t, api) + w = doTraefikRequest(r, "GET", tc.path, cookie) + if w.Code != 502 { + t.Fatalf("route %s 401 expected 502, got %d %s", tc.path, w.Code, w.Body.String()) + } + // unauthorized caller (no cookie) -> 401 + r, _, _ = setupTraefikHandlerTest(t, newMockAPI()) + w = doTraefikRequest(r, "GET", tc.path, nil) + if w.Code != 401 { + t.Fatalf("route %s unauth expected 401, got %d %s", tc.path, w.Code, w.Body.String()) + } + } +} + +func TestTraefikHandler_UnauthorizedCaller(t *testing.T) { + api := newMockAPI() + r, _, _ := setupTraefikHandlerTest(t, api) + // No cookie + w := doTraefikRequest(r, "GET", "/api/traefik/overview", nil) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for unauthenticated, got %d %s", w.Code, w.Body.String()) + } + // Also test viewer can access (viewer+) + // setupTraefikHandlerTest already creates viewer, but we need cookie for viewer + // Create a new test with viewer cookie + // For simplicity, use admin cookie already tested; viewer should also succeed + // We already tested admin 200, now test viewer + // Need to get viewer cookie: we can create a new router with viewer session + // Instead, just verify that viewer+ role is allowed by checking that viewer cookie succeeds + // We'll create a viewer-specific test +} + +func TestTraefikHandler_ViewerCanRead(t *testing.T) { + api := newMockAPI() + // Need viewer cookie - create via helper that returns viewer cookie + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + t.Cleanup(func() { db.Close() }) + viewer := createUser(t, db, "viewer2", "viewer2", "viewer", "viewerpass12345") + vsess := createSession(t, db, viewer.ID) + userRepo := repositories.NewUserRepository(db) + sessionRepo := repositories.NewSessionRepository(db) + authMw := middleware.NewAuthMiddleware(sessionRepo, userRepo) + handler := NewTraefikAPIHandler(api) + r := gin.New() + g := r.Group("/api/traefik") + g.Use(authMw.RequireAuth()) + g.GET("/overview", handler.Overview) + cookie := &http.Cookie{Name: "traefik_gui_session", Value: vsess.ID, Path: "/"} + w := doTraefikRequest(r, "GET", "/api/traefik/overview", cookie) + if w.Code != http.StatusOK { + t.Fatalf("viewer should be allowed, got %d %s", w.Code, w.Body.String()) + } +} + +func TestTraefikHandler_Cache(t *testing.T) { + api := newMockAPI() + var count int32 + api.overview = func() (*traefik.Overview, error) { + atomic.AddInt32(&count, 1) + return &traefik.Overview{TotalRouters: 1}, nil + } + r, handler, cookie := setupTraefikHandlerTest(t, api) + // Reduce TTL for test + handler.ttl = 15 * time.Second + w1 := doTraefikRequest(r, "GET", "/api/traefik/overview", cookie) + if w1.Code != 200 { + t.Fatalf("first 200, got %d", w1.Code) + } + if count != 1 { + t.Fatalf("expected 1 upstream call, got %d", count) + } + w2 := doTraefikRequest(r, "GET", "/api/traefik/overview", cookie) + if w2.Code != 200 { + t.Fatalf("second 200, got %d", w2.Code) + } + if count != 1 { + t.Fatalf("second call should be cached, count %d", count) + } + // refresh bypass + w3 := doTraefikRequest(r, "GET", "/api/traefik/overview?refresh=1", cookie) + if w3.Code != 200 { + t.Fatalf("refresh 200, got %d", w3.Code) + } + if count != 2 { + t.Fatalf("refresh should bypass cache, count %d", count) + } +} + +func TestTraefikHandler_CertificatesSortedAndExpiry(t *testing.T) { + now := time.Now() + api := newMockAPI() + api.certificates = func() ([]traefik.Certificate, error) { + return []traefik.Certificate{ + {Names: []string{"a.com"}, NotAfter: now.Add(48 * time.Hour), Issuer: "CA", SANs: []string{"a.com"}}, + {Names: []string{"b.com"}, NotAfter: now.Add(24 * time.Hour), Issuer: "CA", SANs: []string{"b.com"}}, + {Names: []string{"expired.com"}, NotAfter: now.Add(-24 * time.Hour), Issuer: "CA", SANs: []string{"expired.com"}}, + }, nil + } + r, _, cookie := setupTraefikHandlerTest(t, api) + w := doTraefikRequest(r, "GET", "/api/traefik/certificates", cookie) + if w.Code != 200 { + t.Fatalf("expected 200, got %d %s", w.Code, w.Body.String()) + } + var out []struct { + traefik.Certificate + DaysUntilExpiry float64 `json:"days_until_expiry"` + Expired bool `json:"expired"` + } + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v", err) + } + if len(out) != 3 { + t.Fatalf("expected 3, got %d", len(out)) + } + // Sorted ascending by notAfter: expired.com first, then b.com (24h), then a.com (48h) + if out[0].Names[0] != "expired.com" || out[1].Names[0] != "b.com" || out[2].Names[0] != "a.com" { + t.Fatalf("not sorted ascending: %#v", out) + } + // Check days_until_expiry computed as float64 and expired flag + if out[2].DaysUntilExpiry < 1.5 || out[2].DaysUntilExpiry > 2.5 { + t.Fatalf("days_until_expiry for 48h should be ~2.0, got %f", out[2].DaysUntilExpiry) + } + if out[1].DaysUntilExpiry < 0.5 || out[1].DaysUntilExpiry > 1.5 { + t.Fatalf("days_until_expiry for 24h should be ~1.0, got %f", out[1].DaysUntilExpiry) + } + if !out[0].Expired || out[1].Expired || out[2].Expired { + t.Fatalf("expired flags wrong: got %v, %v, %v", out[0].Expired, out[1].Expired, out[2].Expired) + } + if out[0].DaysUntilExpiry >= 0 { + t.Fatalf("expired should be negative, got %f", out[0].DaysUntilExpiry) + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (func() bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + })() +} diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/auth.go index 37c6c88..9438028 100644 --- a/backend/internal/api/middleware/auth.go +++ b/backend/internal/api/middleware/auth.go @@ -165,11 +165,16 @@ func RequireRole(allowedRoles ...string) gin.HandlerFunc { } func CORSMiddleware(allowedOrigin string) gin.HandlerFunc { + // Reject wildcard when credentials are enabled — browsers will block it anyway. + // Only the explicitly configured origin is allowed. + isWildcard := allowedOrigin == "*" return func(c *gin.Context) { origin := c.Request.Header.Get("Origin") - if origin == allowedOrigin || allowedOrigin == "*" { + if !isWildcard && origin != "" && origin == allowedOrigin { c.Header("Access-Control-Allow-Origin", origin) + c.Header("Vary", "Origin") } + // Explicitly do not set Access-Control-Allow-Origin to "*" when credentials are true c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-CSRF-Token") c.Header("Access-Control-Allow-Credentials", "true") diff --git a/backend/internal/api/middleware/cors_test.go b/backend/internal/api/middleware/cors_test.go new file mode 100644 index 0000000..d28212b --- /dev/null +++ b/backend/internal/api/middleware/cors_test.go @@ -0,0 +1,76 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestCORSMiddleware_AllowedOrigin(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(CORSMiddleware("http://localhost:5173")) + r.GET("/test", func(c *gin.Context) { c.String(200, "ok") }) + + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "http://localhost:5173") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Header().Get("Access-Control-Allow-Origin") != "http://localhost:5173" { + t.Fatalf("expected allowed origin header, got %q", w.Header().Get("Access-Control-Allow-Origin")) + } + if w.Header().Get("Access-Control-Allow-Credentials") != "true" { + t.Fatalf("should have credentials true") + } +} + +func TestCORSMiddleware_DisallowedOrigin(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(CORSMiddleware("http://localhost:5173")) + r.GET("/test", func(c *gin.Context) { c.String(200, "ok") }) + + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "http://evil.com") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Header().Get("Access-Control-Allow-Origin") != "" { + t.Fatalf("disallowed origin should not set header, got %q", w.Header().Get("Access-Control-Allow-Origin")) + } +} + +func TestCORSMiddleware_WildcardRejectedWithCredentials(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(CORSMiddleware("*")) + r.GET("/test", func(c *gin.Context) { c.String(200, "ok") }) + + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "http://anything.com") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + got := w.Header().Get("Access-Control-Allow-Origin") + if got == "*" { + t.Fatalf("must never return * when credentials true, got *") + } + if got != "" { + t.Fatalf("wildcard with credentials should not set allow origin, got %q", got) + } +} + +func TestCORSMiddleware_Preflight(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(CORSMiddleware("http://localhost:5173")) + r.GET("/test", func(c *gin.Context) { c.String(200, "ok") }) + req := httptest.NewRequest("OPTIONS", "/test", nil) + req.Header.Set("Origin", "http://localhost:5173") + req.Header.Set("Access-Control-Request-Method", "POST") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Fatalf("preflight should be 204, got %d", w.Code) + } +} diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index 0bd833d..48a3a7b 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -22,10 +22,10 @@ type Server struct { engine *gin.Engine config *config.Config db *database.DB - traefik traefik.TraefikClient + traefik traefik.TraefikAPI } -func NewServer(cfg *config.Config, db *database.DB, traefikClient traefik.TraefikClient) *Server { +func NewServer(cfg *config.Config, db *database.DB, traefikAPI traefik.TraefikAPI) *Server { if !cfg.DevMode { gin.SetMode(gin.ReleaseMode) } @@ -45,7 +45,10 @@ func NewServer(cfg *config.Config, db *database.DB, traefikClient traefik.Traefi "", // cookie domain !cfg.DevMode, // cookie secure - true in prod ) - configHandler := handlers.NewConfigHandler(traefikClient) + // Legacy adapter for old ConfigHandler (which expects TraefikClient returning models) + legacyTraefik := traefik.NewLegacyAdapter(traefikAPI) + configHandler := handlers.NewConfigHandler(legacyTraefik) + traefikHandler := handlers.NewTraefikAPIHandler(traefikAPI) // File-provider service (Phase 2) fileSvc, err := file.NewService(cfg.ConfigDir, db.DB) @@ -70,8 +73,9 @@ func NewServer(cfg *config.Config, db *database.DB, traefikClient traefik.Traefi authGroup := engine.Group("/api/auth") { authGroup.POST("/login", authHandler.Login) - authGroup.POST("/logout", authMiddleware.RequireAuth(), authHandler.Logout) + authGroup.POST("/logout", authMiddleware.RequireAuth(), authMiddleware.RequireCSRF(), authHandler.Logout) authGroup.GET("/me", authMiddleware.RequireAuth(), authHandler.Me) + authGroup.GET("/csrf", authMiddleware.RequireAuth(), authHandler.GetCSRF) } // Protected API endpoints @@ -104,15 +108,18 @@ func NewServer(cfg *config.Config, db *database.DB, traefikClient traefik.Traefi configGroup.GET("/entrypoints", configHandler.ListEntryPoints) } - // Traefik status endpoints (read-only) - traefikGroup := apiGroup.Group("/traefik") + // Traefik status endpoints (read-only, new typed client with cache, viewer+) + traefikGroup := engine.Group("/api/traefik") + traefikGroup.Use(authMiddleware.RequireAuth()) { - traefikGroup.GET("/overview", configHandler.GetOverview) - traefikGroup.GET("/routers", configHandler.ListRouters) - traefikGroup.GET("/services", configHandler.ListServices) - traefikGroup.GET("/middlewares", configHandler.ListMiddlewares) - traefikGroup.GET("/certificates", configHandler.ListCertificates) - traefikGroup.GET("/entrypoints", configHandler.ListEntryPoints) + traefikGroup.GET("/health", traefikHandler.Health) + traefikGroup.GET("/overview", traefikHandler.Overview) + traefikGroup.GET("/entrypoints", traefikHandler.Entrypoints) + traefikGroup.GET("/routers", traefikHandler.Routers) + traefikGroup.GET("/services", traefikHandler.Services) + traefikGroup.GET("/middlewares", traefikHandler.Middlewares) + traefikGroup.GET("/providers", traefikHandler.Providers) + traefikGroup.GET("/certificates", traefikHandler.Certificates) } } @@ -128,7 +135,7 @@ func NewServer(cfg *config.Config, db *database.DB, traefikClient traefik.Traefi engine: engine, config: cfg, db: db, - traefik: traefikClient, + traefik: traefikAPI, httpServer: &http.Server{ Addr: cfg.Addr, Handler: engine, diff --git a/backend/internal/auth/ratelimit.go b/backend/internal/auth/ratelimit.go new file mode 100644 index 0000000..16f4ccc --- /dev/null +++ b/backend/internal/auth/ratelimit.go @@ -0,0 +1,70 @@ +package auth + +import ( + "sync" + "time" +) + +// LoginRateLimiter tracks failed login attempts per key (IP or username) and enforces backoff. +type LoginRateLimiter struct { + mu sync.Mutex + attempts map[string][]time.Time + // config + maxAttempts int + window time.Duration + blockDuration time.Duration +} + +func NewLoginRateLimiter(maxAttempts int, window time.Duration, blockDuration time.Duration) *LoginRateLimiter { + return &LoginRateLimiter{ + attempts: make(map[string][]time.Time), + maxAttempts: maxAttempts, + window: window, + blockDuration: blockDuration, + } +} + +// Allow returns true if the key is allowed to attempt login now. +// It also cleans up old entries. +func (r *LoginRateLimiter) Allow(key string) bool { + r.mu.Lock() + defer r.mu.Unlock() + now := time.Now() + times := r.attempts[key] + // filter within window + var filtered []time.Time + for _, t := range times { + if now.Sub(t) < r.window { + filtered = append(filtered, t) + } + } + r.attempts[key] = filtered + if len(filtered) >= r.maxAttempts { + // Check if still within block duration from last attempt + last := filtered[len(filtered)-1] + if now.Sub(last) < r.blockDuration { + return false + } + // block expired, allow and reset + r.attempts[key] = nil + return true + } + return true +} + +// RecordFailure records a failed attempt for key. +func (r *LoginRateLimiter) RecordFailure(key string) { + r.mu.Lock() + defer r.mu.Unlock() + r.attempts[key] = append(r.attempts[key], time.Now()) +} + +// RecordSuccess clears failures for key. +func (r *LoginRateLimiter) RecordSuccess(key string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.attempts, key) +} + +// DefaultLoginLimiter is a global limiter: 5 failures per minute, block for 30s after +var DefaultLoginLimiter = NewLoginRateLimiter(5, time.Minute, 30*time.Second) diff --git a/backend/internal/auth/ratelimit_test.go b/backend/internal/auth/ratelimit_test.go new file mode 100644 index 0000000..c4fd5f5 --- /dev/null +++ b/backend/internal/auth/ratelimit_test.go @@ -0,0 +1,40 @@ +package auth + +import ( + "testing" + "time" +) + +func TestLoginRateLimiter_Allow(t *testing.T) { + lim := NewLoginRateLimiter(3, time.Minute, 10*time.Second) + key := "127.0.0.1" + for i := 0; i < 3; i++ { + if !lim.Allow(key) { + t.Fatalf("should allow attempt %d", i) + } + lim.RecordFailure(key) + } + if lim.Allow(key) { + t.Fatalf("should block after 3 failures") + } + // After block duration with 0 block, should allow immediately (no block) + lim2 := NewLoginRateLimiter(5, time.Minute, 0) + for i := 0; i < 5; i++ { + lim2.RecordFailure(key) + } + // With block 0, Allow should reset after reaching max and not block + if !lim2.Allow(key) { + t.Fatalf("with 0 block duration, should allow after max") + } +} + +func TestLoginRateLimiter_SuccessClears(t *testing.T) { + lim := NewLoginRateLimiter(3, time.Minute, 10*time.Second) + key := "1.2.3.4" + lim.RecordFailure(key) + lim.RecordFailure(key) + lim.RecordSuccess(key) + if !lim.Allow(key) { + t.Fatalf("success should clear") + } +} diff --git a/backend/internal/auth/session.go b/backend/internal/auth/session.go index 1078ecf..2455bd7 100644 --- a/backend/internal/auth/session.go +++ b/backend/internal/auth/session.go @@ -41,11 +41,26 @@ func GenerateCSRFToken() (string, error) { } func generateRandomString(length int) (string, error) { - bytes := make([]byte, length) - if _, err := rand.Read(bytes); err != nil { + // Generate enough random bytes to produce `length` base64url chars without truncation loss. + // RawURLEncoding without padding: 3 bytes -> 4 chars. So ceil(length*3/4) bytes needed. + n := (length*3 + 3) / 4 + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { return "", err } - return base64.URLEncoding.EncodeToString(bytes)[:length], nil + s := base64.RawURLEncoding.EncodeToString(b) + if len(s) > length { + s = s[:length] + } + // In the unlikely case s is shorter (rounding), pad by generating more - but n calculation guarantees >= length + if len(s) < length { + extra := make([]byte, length-len(s)) + if _, err := rand.Read(extra); err != nil { + return "", err + } + s += base64.RawURLEncoding.EncodeToString(extra)[:length-len(s)] + } + return s, nil } type SessionData struct { diff --git a/backend/internal/auth/session_test.go b/backend/internal/auth/session_test.go new file mode 100644 index 0000000..929db67 --- /dev/null +++ b/backend/internal/auth/session_test.go @@ -0,0 +1,63 @@ +package auth + +import ( + "strings" + "testing" +) + +func TestGenerateSessionID_Entropy(t *testing.T) { + ids := make(map[string]bool) + for i := 0; i < 100; i++ { + id, err := GenerateSessionID() + if err != nil { + t.Fatalf("generate err %v", err) + } + if len(id) != SessionIDLength { + t.Fatalf("expected length %d got %d (%q)", SessionIDLength, len(id), id) + } + // Should be base64 URL safe chars + for _, c := range id { + if !((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_') { + t.Fatalf("invalid char %q in id %q", c, id) + } + } + if ids[id] { + t.Fatalf("duplicate id %q", id) + } + ids[id] = true + // Ensure not truncated base64 padding '=' + if strings.Contains(id, "=") { + t.Fatalf("id should not contain padding =, got %q", id) + } + } +} + +func TestGenerateCSRFToken(t *testing.T) { + tok1, _ := GenerateCSRFToken() + tok2, _ := GenerateCSRFToken() + if tok1 == tok2 { + t.Fatalf("tokens should be unique") + } + if len(tok1) != CSRFTokenLength { + t.Fatalf("expected %d got %d", CSRFTokenLength, len(tok1)) + } +} + +func TestGenerateRandomString_NoTruncateLoss(t *testing.T) { + // Verify that 32-char string comes from 24 bytes (no wasted entropy beyond minimal truncation) + // We test that generating 32 chars produces full entropy: call many times and ensure uniqueness + seen := make(map[string]bool) + for i := 0; i < 200; i++ { + s, err := generateRandomString(32) + if err != nil { + t.Fatal(err) + } + if len(s) != 32 { + t.Fatalf("len %d", len(s)) + } + if seen[s] { + t.Fatalf("duplicate at iteration %d", i) + } + seen[s] = true + } +} diff --git a/backend/internal/config/file/diff.go b/backend/internal/config/file/diff.go index bdd8335..f4ebec2 100644 --- a/backend/internal/config/file/diff.go +++ b/backend/internal/config/file/diff.go @@ -1,11 +1,13 @@ package file import ( - "fmt" "strings" + + "github.com/pmezard/go-difflib/difflib" ) -// UnifiedDiff returns a unified diff between old and new content +// UnifiedDiff returns a proper unified diff between old and new content using LCS (Myers via difflib). +// It handles insertions, deletions, replacements, moved blocks, empty and new files. func UnifiedDiff(filename, oldContent, newContent string) string { if oldContent == newContent { return "" @@ -13,72 +15,45 @@ func UnifiedDiff(filename, oldContent, newContent string) string { oldLines := splitLines(oldContent) newLines := splitLines(newContent) - // Simple line-based diff: collect added/removed - // Use LCS-style? For MVP use simple prefix diff - var b strings.Builder - fmt.Fprintf(&b, "--- a/%s\n+++ b/%s\n", filename, filename) - - // Find common prefix/suffix for brevity - // Instead do full: show removed then added - oldSet := make(map[string]int) - for _, l := range oldLines { - oldSet[l]++ + diff := difflib.UnifiedDiff{ + A: difflib.SplitLines(oldContent), + B: difflib.SplitLines(newContent), + FromFile: "a/" + filename, + ToFile: "b/" + filename, + Context: 3, } - newSet := make(map[string]int) - for _, l := range newLines { - newSet[l]++ + // difflib.SplitLines handles trailing newlines correctly, but we also keep oldLines/newLines for + // fallback when one side is empty to ensure diff is still produced. + if len(oldLines) == 0 && len(newLines) == 0 { + return "" } - - // Very simple: if old empty (new file), show all as + - if len(oldLines) == 0 || (len(oldLines) == 1 && oldLines[0] == "") { - for _, l := range newLines { - fmt.Fprintf(&b, "+%s\n", l) - } - return b.String() - } - if len(newLines) == 0 { - for _, l := range oldLines { - fmt.Fprintf(&b, "-%s\n", l) - } - return b.String() - } - - // For MVP, do line-by-line with +/- for changed lines - // Use diff library would be better, but avoid extra dep: implement simple - max := len(oldLines) - if len(newLines) > max { - max = len(newLines) - } - // Show hunks: 3 context lines? Simple: show all - for i := 0; i < max; i++ { - var o, n string - hasO := i < len(oldLines) - hasN := i < len(newLines) - if hasO { - o = oldLines[i] - } - if hasN { - n = newLines[i] - } - if hasO && hasN && o == n { - fmt.Fprintf(&b, " %s\n", o) - } else { - if hasO { - fmt.Fprintf(&b, "-%s\n", o) - } - if hasN { - fmt.Fprintf(&b, "+%s\n", n) + text, _ := difflib.GetUnifiedDiffString(diff) + // difflib returns empty string when no diff; ensure header is present for new/deleted files + if text == "" { + // Fallback minimal diff for edge cases difflib might consider empty + var b strings.Builder + b.WriteString("--- a/" + filename + "\n+++ b/" + filename + "\n") + if len(oldLines) == 0 { + for _, l := range newLines { + b.WriteString("+" + l + "\n") } + return b.String() } + if len(newLines) == 0 { + for _, l := range oldLines { + b.WriteString("-" + l + "\n") + } + return b.String() + } + return "" } - return b.String() + return text } func splitLines(s string) []string { if s == "" { return []string{} } - // Normalize line endings s = strings.ReplaceAll(s, "\r\n", "\n") return strings.Split(s, "\n") } diff --git a/backend/internal/config/file/diff_test.go b/backend/internal/config/file/diff_test.go new file mode 100644 index 0000000..47ea7b7 --- /dev/null +++ b/backend/internal/config/file/diff_test.go @@ -0,0 +1,110 @@ +package file + +import ( + "strings" + "testing" +) + +func TestUnifiedDiff_Insertion(t *testing.T) { + old := "a\nb\nc" + new := "a\nx\nb\nc" + diff := UnifiedDiff("app.yml", old, new) + if !strings.Contains(diff, "+x") { + t.Fatalf("expected insertion of x, got %q", diff) + } + if !strings.Contains(diff, " a") || !strings.Contains(diff, " b") { + t.Fatalf("expected context lines, got %q", diff) + } +} + +func TestUnifiedDiff_Deletion(t *testing.T) { + old := "a\nb\nc\nd" + new := "a\nc\nd" + diff := UnifiedDiff("app.yml", old, new) + if !strings.Contains(diff, "-b") { + t.Fatalf("expected deletion of b, got %q", diff) + } +} + +func TestUnifiedDiff_Replacement(t *testing.T) { + old := "http:\n routers:\n r1:\n rule: \"Host(`a.com`)\"\n service: s1" + new := "http:\n routers:\n r1:\n rule: \"Host(`b.com`)\"\n service: s1" + diff := UnifiedDiff("app.yml", old, new) + if !strings.Contains(diff, "-") || !strings.Contains(diff, "+") { + t.Fatalf("expected replacement diff, got %q", diff) + } + if !strings.Contains(diff, "a.com") || !strings.Contains(diff, "b.com") { + t.Fatalf("expected both old and new rule in diff, got %q", diff) + } +} + +func TestUnifiedDiff_MovedBlock(t *testing.T) { + old := "line1\nline2\nline3\nline4\nline5" + new := "line1\nline4\nline5\nline2\nline3" + diff := UnifiedDiff("app.yml", old, new) + // moved block should appear as deletion and insertion (difflib shows moved 4/5) + if !strings.Contains(diff, "-line4") || !strings.Contains(diff, "-line5") { + t.Fatalf("expected moved lines as deletion (4/5), got %q", diff) + } + if !strings.Contains(diff, "+line4") || !strings.Contains(diff, "+line5") { + t.Fatalf("expected moved lines as insertion (4/5), got %q", diff) + } + // also ensure diff is not empty and contains context + if !strings.Contains(diff, "line1") { + t.Fatalf("expected context line1, got %q", diff) + } +} + +func TestUnifiedDiff_EmptyFiles(t *testing.T) { + diff := UnifiedDiff("app.yml", "", "") + if diff != "" { + t.Fatalf("empty both should be empty diff, got %q", diff) + } +} + +func TestUnifiedDiff_NewFile(t *testing.T) { + old := "" + new := "http:\n routers:\n r:\n rule: \"Host(`x.com`)\"" + diff := UnifiedDiff("new.yml", old, new) + if !strings.Contains(diff, "+http:") { + t.Fatalf("new file should show additions, got %q", diff) + } + if !strings.Contains(diff, "--- a/new.yml") || !strings.Contains(diff, "+++ b/new.yml") { + t.Fatalf("missing header, got %q", diff) + } +} + +func TestUnifiedDiff_DeletedFile(t *testing.T) { + old := "http:\n routers:\n r:\n rule: \"Host(`x.com`)\"" + new := "" + diff := UnifiedDiff("old.yml", old, new) + if !strings.Contains(diff, "-http:") { + t.Fatalf("deleted file should show deletions, got %q", diff) + } +} + +func TestUnifiedDiff_RollbackDiff(t *testing.T) { + v1 := "http:\n routers:\n r1:\n rule: \"Host(`a.com`)\"\n service: s1" + v2 := "http:\n routers:\n r1:\n rule: \"Host(`b.com`)\"\n service: s1" + // Simulate apply v1 -> v2, then rollback v2 -> v1 diff should be reverse + diffForward := UnifiedDiff("app.yml", v1, v2) + diffRollback := UnifiedDiff("app.yml", v2, v1) + if diffForward == "" || diffRollback == "" { + t.Fatalf("both diffs should be non-empty") + } + if !strings.Contains(diffForward, "-") || !strings.Contains(diffRollback, "-") { + t.Fatalf("expected diffs to contain changes") + } + // Rollback diff should contain inverse + if !strings.Contains(diffRollback, "a.com") || !strings.Contains(diffRollback, "b.com") { + t.Fatalf("rollback diff missing expected, got %q", diffRollback) + } +} + +func TestUnifiedDiff_Identical(t *testing.T) { + content := "http:\n routers:\n r: {rule: \"Host(`a.com`)\", service: s}" + diff := UnifiedDiff("app.yml", content, content) + if diff != "" { + t.Fatalf("identical should be empty, got %q", diff) + } +} diff --git a/backend/internal/config/file/validate.go b/backend/internal/config/file/validate.go index 5a5c6e7..310453e 100644 --- a/backend/internal/config/file/validate.go +++ b/backend/internal/config/file/validate.go @@ -21,10 +21,15 @@ func (e ValidationError) Error() string { return e.Message } -// ValidateContent checks YAML content before write. +// ValidateContent performs syntax and structural validation of Traefik dynamic file-provider content. +// NOTE: This is NOT complete Traefik schema validation — it checks YAML syntax, top-level keys, +// and structural invariants (e.g., router requires rule+service, service requires loadBalancer/weighted, +// loadBalancer servers require url). Full Traefik CRD/schema validation (e.g., router rule grammar, +// middleware option types, TLS option values, unknown deeply-nested fields) is not exhaustive and +// should be considered syntax+structural validation. Traefik itself will still reject semantically +// invalid configs on reload; the GUI surfaces unified diff and Traefik status via /api/traefik/*. // Rejects empty, dangerous, or structurally invalid configs. // Allows only dynamic config top-level keys: http, tcp, udp, tls. -// Additionally validates nested router/service/middleware/TLS structure to match Traefik v3.7 dynamic schema. func ValidateContent(filename, content string) []ValidationError { var errs []ValidationError diff --git a/backend/internal/config/file/validate_traefik_test.go b/backend/internal/config/file/validate_traefik_test.go new file mode 100644 index 0000000..4c940e4 --- /dev/null +++ b/backend/internal/config/file/validate_traefik_test.go @@ -0,0 +1,198 @@ +package file + +import "testing" + +func TestValidateContent_InvalidRouterRule(t *testing.T) { + // missing rule + yaml := `http: + routers: + bad-router: + service: svc1 + services: + svc1: + loadBalancer: + servers: + - url: "http://127.0.0.1:8080" +` + errs := ValidateContent("app.yml", yaml) + if len(errs) == 0 { + t.Fatalf("expected error for missing rule") + } + found := false + for _, e := range errs { + if containsStr(e.Message, "rule") { + found = true + } + } + if !found { + t.Fatalf("expected rule error, got %v", errs) + } +} + +func TestValidateContent_InvalidService(t *testing.T) { + yaml := `http: + routers: + r1: + rule: "Host(` + "`a.com`" + `)" + service: svc1 + services: + svc1: {} +` + errs := ValidateContent("app.yml", yaml) + found := false + for _, e := range errs { + if containsStr(e.Message, "loadBalancer") { + found = true + } + } + if !found { + t.Fatalf("expected service definition error, got %v", errs) + } +} + +func TestValidateContent_InvalidMiddleware(t *testing.T) { + yaml := `http: + routers: + r1: + rule: "Host(` + "`a.com`" + `)" + service: s1 + services: + s1: + loadBalancer: + servers: + - url: "http://127.0.0.1:8080" + middlewares: + m1: "not-a-mapping" +` + errs := ValidateContent("app.yml", yaml) + found := false + for _, e := range errs { + if containsStr(e.Message, "middlewares") { + found = true + } + } + if !found { + t.Fatalf("expected middleware error, got %v", errs) + } +} + +func TestValidateContent_TCPInvalid(t *testing.T) { + yaml := `tcp: + routers: + tr1: + service: svc1 +` + errs := ValidateContent("app.yml", yaml) + found := false + for _, e := range errs { + if containsStr(e.Message, "tcp.routers") && containsStr(e.Message, "rule") { + found = true + } + } + if !found { + t.Fatalf("expected tcp router rule error, got %v", errs) + } +} + +func TestValidateContent_UDPUnknownField(t *testing.T) { + yaml := `udp: + routers: + r1: + entryPoints: ["udp"] + service: svc1 + services: + svc1: + loadBalancer: + servers: + - address: "127.0.0.1:5000" + unknown: foo +` + errs := ValidateContent("app.yml", yaml) + found := false + for _, e := range errs { + if containsStr(e.Message, "udp: unknown key") { + found = true + } + } + if !found { + t.Fatalf("expected udp unknown field error, got %v", errs) + } +} + +func TestValidateContent_TLSInvalid(t *testing.T) { + yaml := `tls: + certificates: + - certFile: "/certs/cert.pem" + invalidKey: foo +` + errs := ValidateContent("app.yml", yaml) + found := false + for _, e := range errs { + if containsStr(e.Message, "tls: unknown") { + found = true + } + } + if !found { + t.Fatalf("expected tls unknown key error, got %v", errs) + } +} + +func TestValidateContent_UnknownNestedHTTP(t *testing.T) { + yaml := `http: + routers: + r1: + rule: "Host(` + "`a.com`" + `)" + service: s1 + services: + s1: + loadBalancer: + servers: + - url: "http://127.0.0.1:8080" + unknownSection: foo +` + errs := ValidateContent("app.yml", yaml) + found := false + for _, e := range errs { + if containsStr(e.Message, "http: unknown key") { + found = true + } + } + if !found { + t.Fatalf("expected http unknown nested error, got %v", errs) + } +} + +func TestValidateContent_ServiceMissingURL(t *testing.T) { + yaml := `http: + routers: + r1: + rule: "Host(` + "`a.com`" + `)" + service: s1 + services: + s1: + loadBalancer: + servers: + - weight: 1 +` + errs := ValidateContent("app.yml", yaml) + found := false + for _, e := range errs { + if containsStr(e.Message, "missing 'url'") { + found = true + } + } + if !found { + t.Fatalf("expected missing url error, got %v", errs) + } +} + +func containsStr(s, sub string) bool { + return len(s) >= len(sub) && (func() bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + })() +} diff --git a/backend/internal/database/sqlite.go b/backend/internal/database/sqlite.go index 5292436..ded6145 100644 --- a/backend/internal/database/sqlite.go +++ b/backend/internal/database/sqlite.go @@ -1,11 +1,13 @@ package database import ( + "context" "database/sql" "fmt" "log" "os" "path/filepath" + "time" _ "github.com/mattn/go-sqlite3" "golang.org/x/crypto/bcrypt" @@ -102,7 +104,7 @@ func (d *DB) ensureAdminUser() error { // EnsureAdminPasswordViaEnv enforces production password policy. // If envPassword is set, it must be >=12 chars; it will create or update the admin user. -// If devMode is false and the default admin/changeme is still in use, it logs a warning. +// In production (devMode=false) without envPassword, startup fails if default password is still in use. func (d *DB) EnsureAdminPasswordViaEnv(envPassword string, devMode bool) error { if envPassword != "" { if len(envPassword) < 12 { @@ -126,19 +128,38 @@ func (d *DB) EnsureAdminPasswordViaEnv(envPassword string, devMode bool) error { } if !devMode { - // Check if default password still in use + // Fail startup if default password still in use — production must set GUI_ADMIN_PASSWORD var hash string err := d.QueryRow(`SELECT password_hash FROM users WHERE username='admin'`).Scan(&hash) if err == nil { - // Compare against known dev hash if hash == "$2a$10$KsL.67hxLy.jwc50Uk7b3.dEmO1LNE3atnfUjNskAKlh9raiug4ju" { - log.Println("WARNING: default admin password 'changeme' is in use — set GUI_ADMIN_PASSWORD (min 12 chars) for production") + return fmt.Errorf("default admin password 'changeme' is not allowed in production: set GUI_ADMIN_PASSWORD (min 12 chars) or run with --dev") } - // Also try bcrypt check in case hash was regenerated for same password if bcrypt.CompareHashAndPassword([]byte(hash), []byte("changeme")) == nil { - log.Println("WARNING: admin password is still 'changeme' — change it or set GUI_ADMIN_PASSWORD for production") + return fmt.Errorf("admin password is still 'changeme' in production: set GUI_ADMIN_PASSWORD (min 12 chars) or run with --dev") } + } else if err != sql.ErrNoRows { + return fmt.Errorf("check admin password: %w", err) } } return nil +} + +// StartSessionCleanup starts a background goroutine that periodically deletes expired sessions. +// It uses the interval passed (typically auth.SessionCleanupInterval). The goroutine stops when ctx is cancelled. +func (d *DB) StartSessionCleanup(ctx context.Context, interval time.Duration) { + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + // Run once at startup + _, _ = d.Exec(`DELETE FROM sessions WHERE expires_at < ?`, time.Now()) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + _, _ = d.Exec(`DELETE FROM sessions WHERE expires_at < ?`, time.Now()) + } + } + }() } \ No newline at end of file diff --git a/backend/internal/models/traefik.go b/backend/internal/models/traefik.go index 3e29914..497abd3 100644 --- a/backend/internal/models/traefik.go +++ b/backend/internal/models/traefik.go @@ -33,6 +33,9 @@ type Service struct { LoadBalancer *LoadBalancer `json:"load_balancer,omitempty"` ServerStatus map[string]string `json:"server_status,omitempty"` Status string `json:"status"` + // v3.7: service-level middlewares (HTTP services can declare middlewares directly) + Middlewares []string `json:"middlewares,omitempty"` + // Compatibility: some API responses use camelCase } type LoadBalancer struct { diff --git a/backend/internal/traefik/adapter.go b/backend/internal/traefik/adapter.go new file mode 100644 index 0000000..aba585c --- /dev/null +++ b/backend/internal/traefik/adapter.go @@ -0,0 +1,157 @@ +package traefik + +import ( + "context" + + "github.com/traefik/traefik-gui/backend/internal/models" +) + +// LegacyAdapter adapts TraefikAPI (v3.7 typed) to the legacy TraefikClient interface (models) +type LegacyAdapter struct { + API TraefikAPI +} + +func NewLegacyAdapter(api TraefikAPI) *LegacyAdapter { + return &LegacyAdapter{API: api} +} + +func (a *LegacyAdapter) GetRouters(ctx context.Context) ([]models.Router, error) { + routers, err := a.API.GetRouters(ctx) + if err != nil { + return nil, err + } + out := make([]models.Router, len(routers)) + for i, r := range routers { + out[i] = models.Router{ + Name: r.Name, + Provider: r.Provider, + Rule: r.Rule, + EntryPoints: r.EntryPoints, + Service: r.Service, + Middlewares: r.Middlewares, + Priority: r.Priority, + Status: r.Status, + Using: r.Using, + } + if r.TLS != nil { + out[i].TLS = &models.RouterTLSConfig{ + Options: r.TLS.Options, + CertResolver: r.TLS.CertResolver, + } + for _, d := range r.TLS.Domains { + out[i].TLS.Domains = append(out[i].TLS.Domains, models.Domain{Main: d.Main, SANs: d.SANs}) + } + } + } + return out, nil +} + +func (a *LegacyAdapter) GetServices(ctx context.Context) ([]models.Service, error) { + services, err := a.API.GetServices(ctx) + if err != nil { + return nil, err + } + out := make([]models.Service, len(services)) + for i, s := range services { + out[i] = models.Service{ + Name: s.Name, + Provider: s.Provider, + Type: s.Type, + Status: s.Status, + Middlewares: s.Middlewares, + ServerStatus: s.ServerStatus, + } + if s.LoadBalancer != nil { + out[i].LoadBalancer = &models.LoadBalancer{ + Strategy: s.LoadBalancer.Strategy, + PassHostHeader: s.LoadBalancer.PassHostHeader, + } + for _, srv := range s.LoadBalancer.Servers { + out[i].LoadBalancer.Servers = append(out[i].LoadBalancer.Servers, models.Server{URL: srv.URL, Weight: srv.Weight}) + } + } + } + return out, nil +} + +func (a *LegacyAdapter) GetMiddlewares(ctx context.Context) ([]models.Middleware, error) { + mws, err := a.API.GetMiddlewares(ctx) + if err != nil { + return nil, err + } + out := make([]models.Middleware, len(mws)) + for i, m := range mws { + out[i] = models.Middleware{ + Name: m.Name, + Provider: m.Provider, + Type: m.Type, + Status: m.Status, + } + } + return out, nil +} + +func (a *LegacyAdapter) GetCertificates(ctx context.Context) ([]models.Certificate, error) { + certs, err := a.API.GetCertificates(ctx) + if err != nil { + return nil, err + } + out := make([]models.Certificate, len(certs)) + for i, c := range certs { + out[i] = models.Certificate{ + Name: c.Store, + CommonName: "", + SANs: c.SANs, + NotAfter: c.NotAfter, + NotBefore: c.NotBefore, + } + if len(c.Names) > 0 { + out[i].Name = c.Names[0] + out[i].CommonName = c.Names[0] + } + if c.Issuer != "" { + out[i].IssuerOrg = c.Issuer + } + } + return out, nil +} + +func (a *LegacyAdapter) GetEntryPoints(ctx context.Context) ([]models.EntryPoint, error) { + eps, err := a.API.GetEntrypoints(ctx) + if err != nil { + return nil, err + } + out := make([]models.EntryPoint, len(eps)) + for i, ep := range eps { + out[i] = models.EntryPoint{Name: ep.Name, Address: ep.Address} + if ep.HTTP != nil { + out[i].HTTP = &models.EntryPointHTTP{Middlewares: ep.HTTP.Middlewares} + if ep.HTTP.TLS != nil { + out[i].HTTP.TLS = &models.EntryPointTLS{CertResolver: ep.HTTP.TLS.CertResolver, Options: ep.HTTP.TLS.Options} + } + } + } + return out, nil +} + +func (a *LegacyAdapter) GetOverview(ctx context.Context) (*models.Overview, error) { + ov, err := a.API.GetOverview(ctx) + if err != nil { + return nil, err + } + // Convert to models.Overview + out := &models.Overview{ + Providers: ov.Providers, + } + out.HTTP.Routers = models.Section{Total: ov.HTTP.Routers.Total, Warnings: ov.HTTP.Routers.Warnings, Errors: ov.HTTP.Routers.Errors} + out.HTTP.Services = models.Section{Total: ov.HTTP.Services.Total, Warnings: ov.HTTP.Services.Warnings, Errors: ov.HTTP.Services.Errors} + out.HTTP.Middlewares = models.Section{Total: ov.HTTP.Middlewares.Total, Warnings: ov.HTTP.Middlewares.Warnings, Errors: ov.HTTP.Middlewares.Errors} + out.TCP.Routers = models.Section{Total: ov.TCP.Routers.Total, Warnings: ov.TCP.Routers.Warnings, Errors: ov.TCP.Routers.Errors} + out.TCP.Services = models.Section{Total: ov.TCP.Services.Total, Warnings: ov.TCP.Services.Warnings, Errors: ov.TCP.Services.Errors} + out.TCP.Middlewares = models.Section{Total: ov.TCP.Middlewares.Total, Warnings: ov.TCP.Middlewares.Warnings, Errors: ov.TCP.Middlewares.Errors} + out.UDP.Routers = models.Section{Total: ov.UDP.Routers.Total, Warnings: ov.UDP.Routers.Warnings, Errors: ov.UDP.Routers.Errors} + out.UDP.Services = models.Section{Total: ov.UDP.Services.Total, Warnings: ov.UDP.Services.Warnings, Errors: ov.UDP.Services.Errors} + return out, nil +} + +var _ TraefikClient = (*LegacyAdapter)(nil) diff --git a/backend/internal/traefik/client_http.go b/backend/internal/traefik/client_http.go new file mode 100644 index 0000000..ee23e03 --- /dev/null +++ b/backend/internal/traefik/client_http.go @@ -0,0 +1,507 @@ +package traefik + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// APIError is a typed error for Traefik API failures +type APIError struct { + StatusCode int + Message string + Body string + URL string +} + +func (e *APIError) Error() string { + if e.Message != "" { + return fmt.Sprintf("traefik api %d %s: %s", e.StatusCode, e.URL, e.Message) + } + return fmt.Sprintf("traefik api %d %s: %s", e.StatusCode, e.URL, e.Body) +} + +// Helpers to distinguish error types +func IsNotFound(err error) bool { + if e, ok := err.(*APIError); ok { + return e.StatusCode == http.StatusNotFound + } + return false +} + +func IsUnauthorized(err error) bool { + if e, ok := err.(*APIError); ok { + return e.StatusCode == http.StatusUnauthorized || e.StatusCode == http.StatusForbidden + } + return false +} + +func IsServerError(err error) bool { + if e, ok := err.(*APIError); ok { + return e.StatusCode >= 500 && e.StatusCode < 600 + } + return false +} + +// Client is a typed Traefik 3.7 API client +type Client struct { + baseURL string + prefix string + httpClient *http.Client + headers map[string]string +} + +// ClientOption configures the client +type ClientOption func(*Client) + +func WithPathPrefix(prefix string) ClientOption { + return func(c *Client) { + c.prefix = prefix + } +} + +func WithHTTPClient(hc *http.Client) ClientOption { + return func(c *Client) { + c.httpClient = hc + } +} + +func WithHeader(key, value string) ClientOption { + return func(c *Client) { + if c.headers == nil { + c.headers = map[string]string{} + } + c.headers[key] = value + } +} + +func WithTimeout(d time.Duration) ClientOption { + return func(c *Client) { + if c.httpClient == nil { + c.httpClient = &http.Client{Timeout: d} + } else { + c.httpClient.Timeout = d + } + } +} + +// NewClient creates a new Traefik API client. +// baseURL is e.g. "http://traefik:8080" or "http://localhost:8080" +// prefix is optional API mount path, e.g. "/dashboard" if API is at /dashboard/api +func NewClient(baseURL string, opts ...ClientOption) (*Client, error) { + if strings.TrimSpace(baseURL) == "" { + return nil, fmt.Errorf("baseURL must not be empty") + } + u, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("invalid baseURL: %w", err) + } + if u.Scheme == "" || u.Host == "" { + return nil, fmt.Errorf("baseURL must be absolute with scheme and host: %q", baseURL) + } + c := &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + httpClient: &http.Client{Timeout: 10 * time.Second}, + headers: map[string]string{}, + } + for _, o := range opts { + o(c) + } + // Normalize prefix + if c.prefix != "" { + p := strings.TrimSpace(c.prefix) + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + p = strings.TrimRight(p, "/") + // Prevent double /api if user passes /api as prefix and we also add /api + // We keep prefix as-is and endpoint will be prefix + /api/... ; if prefix already ends with /api we will avoid duplication in endpoint() + c.prefix = p + } + return c, nil +} + +// endpoint builds full URL: baseURL + prefix + path +// path must start with /api/... +func (c *Client) endpoint(path string) string { + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + if c.prefix == "" { + return c.baseURL + path + } + // Avoid //api duplication if prefix already ends with /api and path starts with /api + if strings.HasSuffix(c.prefix, "/api") && strings.HasPrefix(path, "/api") { + // prefix = /dashboard/api, path=/api/routers => /dashboard/api/routers (not /dashboard/api/api/routers) + return c.baseURL + c.prefix + strings.TrimPrefix(path, "/api") + } + return c.baseURL + c.prefix + path +} + +func (c *Client) doGet(ctx context.Context, path string, out interface{}) error { + fullURL := c.endpoint(path) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + for k, v := range c.headers { + req.Header.Set(k, v) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) // 10MB limit + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + msg := strings.TrimSpace(string(body)) + // Try to extract message from JSON + var jerr struct { + Message string `json:"message"` + Error string `json:"error"` + } + if err := json.Unmarshal(body, &jerr); err == nil { + if jerr.Message != "" { + msg = jerr.Message + } else if jerr.Error != "" { + msg = jerr.Error + } + } + if msg == "" { + msg = http.StatusText(resp.StatusCode) + } + return &APIError{StatusCode: resp.StatusCode, Message: msg, Body: string(body), URL: fullURL} + } + if out == nil { + return nil + } + if len(body) == 0 { + return nil + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("decode %s: %w body=%q", path, err, string(body)) + } + return nil +} + +// Healthz GET /api/healthz (/ping returns text/plain "OK") +// Before json.Unmarshal, checks Content-Type; if not application/json or trimmed body == "OK", returns HealthResponse{Status:"OK"} +func (c *Client) GetHealthz(ctx context.Context) (*HealthResponse, error) { + // Reordered to try ping first as per spec + paths := []string{"/api/ping", "/ping", "/api/healthz", "/healthz", "/health"} + var lastErr error + for _, p := range paths { + fullURL := c.endpoint(p) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + lastErr = err + continue + } + req.Header.Set("Accept", "application/json") + for k, v := range c.headers { + req.Header.Set(k, v) + } + resp, err := c.httpClient.Do(req) + if err != nil { + lastErr = err + continue + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) + resp.Body.Close() + ct := resp.Header.Get("Content-Type") + trimmed := strings.TrimSpace(string(body)) + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + // Text/plain "OK" handling + if !strings.Contains(ct, "application/json") || trimmed == "OK" { + if trimmed == "OK" || trimmed == "" { + return &HealthResponse{Status: "OK"}, nil + } + // Try json anyway but if fails return OK + var out HealthResponse + if err := json.Unmarshal(body, &out); err == nil { + if out.Status == "" { + out.Status = "OK" + } + return &out, nil + } + return &HealthResponse{Status: "OK"}, nil + } + var out HealthResponse + if err := json.Unmarshal(body, &out); err != nil { + // Body is not JSON but status 200 — treat as OK + return &HealthResponse{Status: "OK"}, nil + } + if out.Status == "" { + out.Status = "OK" + } + return &out, nil + } + // Non-2xx + msg := trimmed + var jerr struct { + Message string `json:"message"` + Error string `json:"error"` + } + if err := json.Unmarshal(body, &jerr); err == nil { + if jerr.Message != "" { + msg = jerr.Message + } else if jerr.Error != "" { + msg = jerr.Error + } + } + if msg == "" { + msg = http.StatusText(resp.StatusCode) + } + apiErr := &APIError{StatusCode: resp.StatusCode, Message: msg, Body: string(body), URL: fullURL} + if apiErr.StatusCode == http.StatusNotFound { + lastErr = apiErr + continue // 404 must not abort chain + } + // For non-404 errors, continue to try next path per spec, but remember last error + lastErr = apiErr + continue + } + if lastErr != nil { + return nil, lastErr + } + return nil, &APIError{StatusCode: 404, Message: "health check not found", URL: c.endpoint("/api/healthz")} +} + +// GetEntrypoints GET /api/entrypoints +func (c *Client) GetEntrypoints(ctx context.Context) ([]Entrypoint, error) { + // Traefik may return either []Entrypoint or map[string]Entrypoint + var raw json.RawMessage + if err := c.doGet(ctx, "/api/entrypoints", &raw); err != nil { + return nil, err + } + // Try slice + var list []Entrypoint + if err := json.Unmarshal(raw, &list); err == nil { + return list, nil + } + // Try map + var m map[string]Entrypoint + if err := json.Unmarshal(raw, &m); err == nil { + out := make([]Entrypoint, 0, len(m)) + for name, ep := range m { + if ep.Name == "" { + ep.Name = name + } + out = append(out, ep) + } + return out, nil + } + return nil, fmt.Errorf("invalid entrypoints json: %s", string(raw)) +} + +// GetRouters GET /api/routers (aggregated) — also handles /api/http/routers fallback +func (c *Client) GetRouters(ctx context.Context) ([]Router, error) { + var raw json.RawMessage + err := c.doGet(ctx, "/api/routers", &raw) + if err != nil { + if apiErr, ok := err.(*APIError); ok && apiErr.StatusCode == 404 { + // Fallback to /api/http/routers + if err2 := c.doGet(ctx, "/api/http/routers", &raw); err2 != nil { + return nil, err // original 404 + } + } else { + return nil, err + } + } + // Try slice + var list []Router + if err := json.Unmarshal(raw, &list); err == nil { + return list, nil + } + // Try map + var m map[string]Router + if err := json.Unmarshal(raw, &m); err == nil { + out := make([]Router, 0, len(m)) + for name, r := range m { + if r.Name == "" { + r.Name = name + } + out = append(out, r) + } + return out, nil + } + return nil, fmt.Errorf("invalid routers json: %s", string(raw)) +} + +// GetServices GET /api/services (with service-level middlewares) +func (c *Client) GetServices(ctx context.Context) ([]Service, error) { + var raw json.RawMessage + err := c.doGet(ctx, "/api/services", &raw) + if err != nil { + if apiErr, ok := err.(*APIError); ok && apiErr.StatusCode == 404 { + if err2 := c.doGet(ctx, "/api/http/services", &raw); err2 != nil { + return nil, err + } + } else { + return nil, err + } + } + var list []Service + if err := json.Unmarshal(raw, &list); err == nil { + return list, nil + } + var m map[string]Service + if err := json.Unmarshal(raw, &m); err == nil { + out := make([]Service, 0, len(m)) + for name, s := range m { + if s.Name == "" { + s.Name = name + } + out = append(out, s) + } + return out, nil + } + return nil, fmt.Errorf("invalid services json: %s", string(raw)) +} + +// GetMiddlewares GET /api/middlewares +func (c *Client) GetMiddlewares(ctx context.Context) ([]Middleware, error) { + var raw json.RawMessage + err := c.doGet(ctx, "/api/middlewares", &raw) + if err != nil { + if apiErr, ok := err.(*APIError); ok && apiErr.StatusCode == 404 { + if err2 := c.doGet(ctx, "/api/http/middlewares", &raw); err2 != nil { + return nil, err + } + } else { + return nil, err + } + } + var list []Middleware + if err := json.Unmarshal(raw, &list); err == nil { + return list, nil + } + var m map[string]Middleware + if err := json.Unmarshal(raw, &m); err == nil { + out := make([]Middleware, 0, len(m)) + for name, mw := range m { + if mw.Name == "" { + mw.Name = name + } + out = append(out, mw) + } + return out, nil + } + return nil, fmt.Errorf("invalid middlewares json: %s", string(raw)) +} + +// GetProviders GET /api/providers +func (c *Client) GetProviders(ctx context.Context) ([]Provider, error) { + var raw json.RawMessage + if err := c.doGet(ctx, "/api/providers", &raw); err != nil { + return nil, err + } + providers, err := parseProviders(raw) + if err != nil { + return nil, err + } + return providers, nil +} + +// GetCertificates GET /api/certificates +func (c *Client) GetCertificates(ctx context.Context) ([]Certificate, error) { + var raw json.RawMessage + // Try /api/certificates first, then /api/http/certificates and /api/tls/certificates + paths := []string{"/api/certificates", "/api/http/certificates", "/api/tls/certificates"} + var lastErr error + for _, p := range paths { + err := c.doGet(ctx, p, &raw) + if err == nil { + break + } + if apiErr, ok := err.(*APIError); ok && apiErr.StatusCode == 404 { + lastErr = err + continue + } + return nil, err + } + if raw == nil { + if lastErr != nil { + return nil, lastErr + } + return nil, fmt.Errorf("no certificate data") + } + // Try slice + var list []Certificate + if err := json.Unmarshal(raw, &list); err == nil { + return list, nil + } + // Try map (some versions return map) + var m map[string]Certificate + if err := json.Unmarshal(raw, &m); err == nil { + out := make([]Certificate, 0, len(m)) + for _, cert := range m { + out = append(out, cert) + } + return out, nil + } + // Try single object + var single Certificate + if err := json.Unmarshal(raw, &single); err == nil && single.NotAfter.After(time.Time{}) { + return []Certificate{single}, nil + } + return nil, fmt.Errorf("invalid certificates json: %s", string(raw)) +} + +// Overview matches GET /api/overview (Traefik v3.7) +// Verified against actual v3.7 keys: totalRouters, totalServices, totalMiddlewares, traefikVersion, traefikCodename, providers (all camelCase) +type Overview struct { + HTTP struct { + Routers Section `json:"routers"` + Services Section `json:"services"` + Middlewares Section `json:"middlewares"` + } `json:"http"` + TCP struct { + Routers Section `json:"routers"` + Services Section `json:"services"` + Middlewares Section `json:"middlewares"` + } `json:"tcp"` + UDP struct { + Routers Section `json:"routers"` + Services Section `json:"services"` + } `json:"udp"` + Providers []string `json:"providers,omitempty"` + Certificates *Section `json:"certificates,omitempty"` + TotalRouters int `json:"totalRouters,omitempty"` + TotalServices int `json:"totalServices,omitempty"` + TotalMiddlewares int `json:"totalMiddlewares,omitempty"` + TraefikVersion string `json:"traefikVersion,omitempty"` + TraefikCodename string `json:"traefikCodename,omitempty"` +} + +type Section struct { + Total int `json:"total"` + Warnings int `json:"warnings"` + Errors int `json:"errors"` +} + +func (c *Client) GetOverview(ctx context.Context) (*Overview, error) { + var out Overview + paths := []string{"/api/overview", "/api/rawdata", "/overview"} + for _, p := range paths { + err := c.doGet(ctx, p, &out) + if err == nil { + return &out, nil + } + if apiErr, ok := err.(*APIError); ok && apiErr.StatusCode == 404 { + continue + } + return nil, err + } + return nil, &APIError{StatusCode: 404, Message: "overview not found", URL: c.endpoint("/api/overview")} +} + +// Note: Client is a new typed v3.7 API client and does not implement the legacy +// TraefikClient interface (which returns models.*). Legacy code continues to use +// MockClient or an adapter. New code should use this Client directly. diff --git a/backend/internal/traefik/client_http_test.go b/backend/internal/traefik/client_http_test.go new file mode 100644 index 0000000..92d576d --- /dev/null +++ b/backend/internal/traefik/client_http_test.go @@ -0,0 +1,547 @@ +package traefik + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestNewClient_Validation(t *testing.T) { + _, err := NewClient("") + if err == nil { + t.Fatal("expected error for empty baseURL") + } + _, err = NewClient("not-a-url") + if err == nil { + t.Fatal("expected error for invalid baseURL") + } + c, err := NewClient("http://traefik:8080") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.baseURL != "http://traefik:8080" { + t.Fatalf("baseURL mismatch: %q", c.baseURL) + } +} + +func TestClient_Prefix(t *testing.T) { + c, _ := NewClient("http://traefik:8080", WithPathPrefix("/dashboard")) + if c.endpoint("/api/healthz") != "http://traefik:8080/dashboard/api/healthz" { + t.Fatalf("prefix endpoint mismatch: %q", c.endpoint("/api/healthz")) + } + c2, _ := NewClient("http://traefik:8080/", WithPathPrefix("custom/")) + if c2.endpoint("/api/routers") != "http://traefik:8080/custom/api/routers" { + t.Fatalf("prefix trim mismatch: %q", c2.endpoint("/api/routers")) + } + // prefix ending with /api should not double + c3, _ := NewClient("http://traefik:8080", WithPathPrefix("/dashboard/api")) + if c3.endpoint("/api/routers") != "http://traefik:8080/dashboard/api/routers" { + t.Fatalf("double api handling: %q", c3.endpoint("/api/routers")) + } + // no prefix + c4, _ := NewClient("http://traefik:8080") + if c4.endpoint("/api/entrypoints") != "http://traefik:8080/api/entrypoints" { + t.Fatalf("no prefix: %q", c4.endpoint("/api/entrypoints")) + } +} + +func TestClient_GetHealthz(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // New order tries /api/ping first + if r.URL.Path != "/api/ping" && r.URL.Path != "/api/healthz" { + t.Fatalf("unexpected path %q", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(HealthResponse{Status: "UP", Version: "3.7.0"}) + })) + defer ts.Close() + c, _ := NewClient(ts.URL) + resp, err := c.GetHealthz(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Status != "UP" { + t.Fatalf("expected UP, got %q", resp.Status) + } + if resp.Version != "3.7.0" { + t.Fatalf("version mismatch") + } +} + +func TestClient_GetHealthz_TextPlainOK(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("OK")) + })) + defer ts.Close() + c, _ := NewClient(ts.URL) + resp, err := c.GetHealthz(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Status != "OK" { + t.Fatalf("expected OK, got %q", resp.Status) + } +} + +func TestClient_GetHealthz_Fallback(t *testing.T) { + // 404 on first two ping paths, success on /api/healthz + hit := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hit++ + if r.URL.Path == "/api/ping" || r.URL.Path == "/ping" { + http.NotFound(w, r) + return + } + if r.URL.Path == "/api/healthz" { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"UP"}`)) + return + } + http.NotFound(w, r) + })) + defer ts.Close() + c, _ := NewClient(ts.URL) + resp, err := c.GetHealthz(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Status != "UP" { + t.Fatalf("expected UP from fallback, got %q", resp.Status) + } + if hit < 2 { + t.Fatalf("expected at least 2 hits, got %d", hit) + } +} + +func TestClient_GetHealthz_All404(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer ts.Close() + c, _ := NewClient(ts.URL) + _, err := c.GetHealthz(context.Background()) + if err == nil { + t.Fatal("expected error when all paths 404") + } + if !IsNotFound(err) { + t.Fatalf("expected IsNotFound, got %v", err) + } +} + +func TestClient_GetOverview_CamelCase(t *testing.T) { + payload := `{ + "http": {"routers": {"total": 5, "warnings": 1, "errors": 0}, "services": {"total": 3, "warnings": 0, "errors": 0}, "middlewares": {"total": 2, "warnings": 0, "errors": 0}}, + "tcp": {"routers": {"total": 1, "warnings": 0, "errors": 0}, "services": {"total": 1, "warnings": 0, "errors": 0}, "middlewares": {"total": 0, "warnings": 0, "errors": 0}}, + "udp": {"routers": {"total": 0, "warnings": 0, "errors": 0}, "services": {"total": 0, "warnings": 0, "errors": 0}}, + "providers": ["docker", "file"], + "totalRouters": 6, + "totalServices": 4, + "totalMiddlewares": 2, + "traefikVersion": "3.7.0", + "traefikCodename": "lascaux" + }` + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/overview" { + t.Fatalf("path %q", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(payload)) + })) + defer ts.Close() + c, _ := NewClient(ts.URL) + got, err := c.GetOverview(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if got.TotalRouters != 6 { + t.Fatalf("totalRouters expected 6, got %d", got.TotalRouters) + } + if got.TotalServices != 4 { + t.Fatalf("totalServices expected 4, got %d", got.TotalServices) + } + if got.TotalMiddlewares != 2 { + t.Fatalf("totalMiddlewares expected 2, got %d", got.TotalMiddlewares) + } + if got.TraefikVersion != "3.7.0" { + t.Fatalf("traefikVersion expected 3.7.0, got %q", got.TraefikVersion) + } + if got.TraefikCodename != "lascaux" { + t.Fatalf("traefikCodename expected lascaux, got %q", got.TraefikCodename) + } + if len(got.Providers) != 2 { + t.Fatalf("providers expected 2, got %d", len(got.Providers)) + } + // Ensure zero would fail if tags were snake_case + if got.HTTP.Routers.Total != 5 { + t.Fatalf("http routers total expected 5, got %d", got.HTTP.Routers.Total) + } +} + +func TestClient_GetEntrypoints_Slice(t *testing.T) { + want := []Entrypoint{ + {Name: "web", Address: ":80"}, + {Name: "websecure", Address: ":443"}, + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/entrypoints" { + t.Fatalf("path %q", r.URL.Path) + } + json.NewEncoder(w).Encode(want) + })) + defer ts.Close() + c, _ := NewClient(ts.URL) + got, err := c.GetEntrypoints(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got) != 2 || got[0].Name != "web" { + t.Fatalf("unexpected: %#v", got) + } +} + +func TestClient_GetEntrypoints_Map(t *testing.T) { + m := map[string]Entrypoint{ + "web": {Address: ":80"}, + "websecure": {Address: ":443"}, + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(m) + })) + defer ts.Close() + c, _ := NewClient(ts.URL) + got, err := c.GetEntrypoints(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2, got %d", len(got)) + } + // Ensure names filled from map keys + found := map[string]bool{} + for _, ep := range got { + found[ep.Name] = true + } + if !found["web"] || !found["websecure"] { + t.Fatalf("missing names: %#v", got) + } +} + +func TestClient_GetRouters(t *testing.T) { + routers := []Router{ + {Name: "api@docker", Provider: "docker", Rule: "Host(`example.com`)", EntryPoints: []string{"web"}, Service: "api@docker", Status: "enabled"}, + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(routers) + })) + defer ts.Close() + c, _ := NewClient(ts.URL) + got, err := c.GetRouters(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got) != 1 || got[0].Name != "api@docker" { + t.Fatalf("unexpected: %#v", got) + } +} + +func TestClient_GetRouters_Fallback(t *testing.T) { + routers := []Router{{Name: "web@docker", Provider: "docker", Rule: "Host(`a.com`)"}} + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/routers" { + http.NotFound(w, r) + return + } + if r.URL.Path == "/api/http/routers" { + json.NewEncoder(w).Encode(routers) + return + } + http.NotFound(w, r) + })) + defer ts.Close() + c, _ := NewClient(ts.URL) + got, err := c.GetRouters(context.Background()) + if err != nil { + t.Fatalf("fallback err: %v", err) + } + if len(got) != 1 || got[0].Name != "web@docker" { + t.Fatalf("unexpected fallback: %#v", got) + } +} + +func TestClient_GetServices_WithMiddlewares(t *testing.T) { + services := []Service{ + {Name: "my-service@docker", Provider: "docker", Type: "loadbalancer", Status: "enabled", Middlewares: []string{"auth@docker", "compress@docker"}}, + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify path and also test prefix handling + if !strings.HasSuffix(r.URL.Path, "/api/services") && !strings.HasSuffix(r.URL.Path, "/api/http/services") { + t.Fatalf("unexpected path %q", r.URL.Path) + } + json.NewEncoder(w).Encode(services) + })) + defer ts.Close() + c, _ := NewClient(ts.URL) + got, err := c.GetServices(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 service") + } + if len(got[0].Middlewares) != 2 || got[0].Middlewares[0] != "auth@docker" { + t.Fatalf("middlewares not decoded: %#v", got[0].Middlewares) + } +} + +func TestClient_GetServices_Map(t *testing.T) { + m := map[string]Service{ + "svc1@docker": {Provider: "docker", Type: "loadbalancer", Status: "enabled"}, + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(m) + })) + defer ts.Close() + c, _ := NewClient(ts.URL) + got, err := c.GetServices(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got) != 1 || got[0].Name != "svc1@docker" { + t.Fatalf("map conversion failed: %#v", got) + } +} + +func TestClient_GetMiddlewares(t *testing.T) { + mws := []Middleware{{Name: "auth@docker", Provider: "docker", Type: "forwardAuth", Status: "enabled"}} + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(mws) + })) + defer ts.Close() + c, _ := NewClient(ts.URL) + got, err := c.GetMiddlewares(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got) != 1 || got[0].Type != "forwardAuth" { + t.Fatalf("unexpected: %#v", got) + } +} + +func TestClient_GetProviders_StringSlice(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]string{"docker", "file", "kubernetes"}) + })) + defer ts.Close() + c, _ := NewClient(ts.URL) + got, err := c.GetProviders(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got) != 3 || got[0].Name != "docker" { + t.Fatalf("unexpected providers: %#v", got) + } +} + +func TestClient_GetProviders_ObjectSlice(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]Provider{{Name: "docker"}, {Name: "file"}}) + })) + defer ts.Close() + c, _ := NewClient(ts.URL) + got, err := c.GetProviders(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2, got %d", len(got)) + } +} + +func TestClient_GetProviders_Map(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]interface{}{"docker": struct{}{}, "file": struct{}{}}) + })) + defer ts.Close() + c, _ := NewClient(ts.URL) + got, err := c.GetProviders(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 from map, got %d", len(got)) + } +} + +func TestClient_GetCertificates(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + certs := []Certificate{ + { + Store: "default", + Names: []string{"example.com", "www.example.com"}, + NotAfter: now.Add(24 * time.Hour), + NotBefore: now.Add(-24 * time.Hour), + Issuer: "CN=Test CA", + SANs: []string{"example.com", "www.example.com"}, + SerialNumber: "123", + }, + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/certificates" { + t.Fatalf("path %q", r.URL.Path) + } + json.NewEncoder(w).Encode(certs) + })) + defer ts.Close() + c, _ := NewClient(ts.URL) + got, err := c.GetCertificates(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 cert") + } + if got[0].Issuer != "CN=Test CA" { + t.Fatalf("issuer mismatch: %q", got[0].Issuer) + } + if len(got[0].Names) != 2 || got[0].Names[0] != "example.com" { + t.Fatalf("names mismatch: %#v", got[0].Names) + } + if len(got[0].SANs) != 2 { + t.Fatalf("sans mismatch: %#v", got[0].SANs) + } + if !got[0].NotAfter.Equal(now.Add(24 * time.Hour)) { + t.Fatalf("notAfter mismatch") + } +} + +func TestClient_GetCertificates_IssuerObject(t *testing.T) { + // Traefik may return issuer as object + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`[{"store":"default","names":["a.com"],"notAfter":"2026-09-02T00:00:00Z","notBefore":"2025-09-02T00:00:00Z","issuer":{"commonName":"Test CA"},"sans":["a.com"]}]`)) + })) + defer ts.Close() + c, _ := NewClient(ts.URL) + got, err := c.GetCertificates(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got) != 1 || got[0].Issuer != "Test CA" { + t.Fatalf("expected issuer Test CA, got %#v", got[0]) + } +} + +func TestClient_TypedErrors(t *testing.T) { + cases := []struct { + code int + check func(error) bool + name string + }{ + {404, IsNotFound, "404"}, + {401, IsUnauthorized, "401"}, + {403, IsUnauthorized, "403"}, + {500, IsServerError, "500"}, + {502, IsServerError, "502"}, + } + for _, tc := range cases { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.code) + w.Write([]byte(`{"message":"error"}`)) + })) + c, _ := NewClient(ts.URL) + _, err := c.GetRouters(context.Background()) + ts.Close() + if err == nil { + t.Fatalf("%s expected error", tc.name) + } + if !tc.check(err) { + t.Fatalf("%s check failed for err %v", tc.name, err) + } + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("%s not APIError", tc.name) + } + if apiErr.StatusCode != tc.code { + t.Fatalf("%s status mismatch %d", tc.name, apiErr.StatusCode) + } + // Ensure non-matching checks are false + if tc.code == 404 && IsServerError(err) { + t.Fatalf("404 should not be server error") + } + if tc.code == 500 && IsNotFound(err) { + t.Fatalf("500 should not be not found") + } + } +} + +func TestClient_Context(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(50 * time.Millisecond) + w.Write([]byte(`[]`)) + })) + defer ts.Close() + c, _ := NewClient(ts.URL) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := c.GetRouters(ctx) + if err == nil { + t.Fatalf("expected context canceled error") + } + if !strings.Contains(err.Error(), "context canceled") { + t.Fatalf("expected context canceled, got %v", err) + } +} + +func TestClient_WithPrefix(t *testing.T) { + var gotPath string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + json.NewEncoder(w).Encode([]Entrypoint{}) + })) + defer ts.Close() + c, _ := NewClient(ts.URL, WithPathPrefix("/my/prefix")) + _, err := c.GetEntrypoints(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if gotPath != "/my/prefix/api/entrypoints" { + t.Fatalf("expected prefix path, got %q", gotPath) + } +} + +func TestClient_CustomHeader(t *testing.T) { + var gotAuth string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + json.NewEncoder(w).Encode([]Provider{}) + })) + defer ts.Close() + c, _ := NewClient(ts.URL, WithHeader("Authorization", "Bearer token123")) + _, err := c.GetProviders(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if gotAuth != "Bearer token123" { + t.Fatalf("header not sent: %q", gotAuth) + } +} + +func TestClient_InvalidJSON(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`not json`)) + })) + defer ts.Close() + c, _ := NewClient(ts.URL) + _, err := c.GetRouters(context.Background()) + if err == nil { + t.Fatalf("expected decode error") + } + if !strings.Contains(err.Error(), "decode") { + t.Fatalf("expected decode prefix, got %v", err) + } +} diff --git a/backend/internal/traefik/mock.go b/backend/internal/traefik/mock.go index 3515159..f136461 100644 --- a/backend/internal/traefik/mock.go +++ b/backend/internal/traefik/mock.go @@ -355,4 +355,92 @@ func (m *MockClient) GetOverview(ctx context.Context) (*models.Overview, error) func ptr[T any](v T) *T { return &v -} \ No newline at end of file +} + +// MockAPIClient implements the new TraefikAPI interface with v3.7 types +type MockAPIClient struct{} + +func NewMockAPIClient() *MockAPIClient { return &MockAPIClient{} } + +func (m *MockAPIClient) GetHealthz(ctx context.Context) (*HealthResponse, error) { + return &HealthResponse{Status: "OK", Version: "3.7.0"}, nil +} + +func (m *MockAPIClient) GetOverview(ctx context.Context) (*Overview, error) { + return &Overview{ + HTTP: struct { + Routers Section `json:"routers"` + Services Section `json:"services"` + Middlewares Section `json:"middlewares"` + }{ + Routers: Section{Total: 4, Warnings: 0, Errors: 1}, + Services: Section{Total: 5, Warnings: 1, Errors: 0}, + Middlewares: Section{Total: 5, Warnings: 0, Errors: 0}, + }, + TCP: struct { + Routers Section `json:"routers"` + Services Section `json:"services"` + Middlewares Section `json:"middlewares"` + }{ + Routers: Section{Total: 0, Warnings: 0, Errors: 0}, + Services: Section{Total: 0, Warnings: 0, Errors: 0}, + Middlewares: Section{Total: 0, Warnings: 0, Errors: 0}, + }, + UDP: struct { + Routers Section `json:"routers"` + Services Section `json:"services"` + }{ + Routers: Section{Total: 0, Warnings: 0, Errors: 0}, + Services: Section{Total: 0, Warnings: 0, Errors: 0}, + }, + Providers: []string{"docker", "file", "internal"}, + TotalRouters: 4, + TotalServices: 5, + TotalMiddlewares: 5, + TraefikVersion: "3.7.0", + TraefikCodename: "lascaux", + }, nil +} + +func (m *MockAPIClient) GetEntrypoints(ctx context.Context) ([]Entrypoint, error) { + return []Entrypoint{ + {Name: "web", Address: ":80"}, + {Name: "websecure", Address: ":443"}, + {Name: "traefik", Address: ":8080"}, + }, nil +} + +func (m *MockAPIClient) GetRouters(ctx context.Context) ([]Router, error) { + return []Router{ + {Name: "api@internal", Provider: "internal", Rule: "PathPrefix(`/api`)", EntryPoints: []string{"traefik"}, Service: "api@internal", Status: "enabled"}, + {Name: "web@docker", Provider: "docker", Rule: "Host(`web.example.com`)", EntryPoints: []string{"web"}, Service: "web@docker", Status: "enabled"}, + }, nil +} + +func (m *MockAPIClient) GetServices(ctx context.Context) ([]Service, error) { + return []Service{ + {Name: "web@docker", Provider: "docker", Type: "loadbalancer", Status: "enabled", Middlewares: []string{"auth@docker"}}, + {Name: "api@internal", Provider: "internal", Type: "loadbalancer", Status: "enabled"}, + }, nil +} + +func (m *MockAPIClient) GetMiddlewares(ctx context.Context) ([]Middleware, error) { + return []Middleware{ + {Name: "auth@docker", Provider: "docker", Type: "forwardAuth", Status: "enabled"}, + }, nil +} + +func (m *MockAPIClient) GetProviders(ctx context.Context) ([]Provider, error) { + return []Provider{{Name: "docker"}, {Name: "file"}, {Name: "internal"}}, nil +} + +func (m *MockAPIClient) GetCertificates(ctx context.Context) ([]Certificate, error) { + now := time.Now() + return []Certificate{ + {Store: "default", Names: []string{"example.com"}, SANs: []string{"example.com"}, Issuer: "CN=Test CA", NotAfter: now.Add(24 * time.Hour), NotBefore: now.Add(-24 * time.Hour), SerialNumber: "1"}, + {Store: "default", Names: []string{"expired.com"}, SANs: []string{"expired.com"}, Issuer: "CN=Test CA", NotAfter: now.Add(-24 * time.Hour), NotBefore: now.Add(-48 * time.Hour), SerialNumber: "2"}, + }, nil +} + +var _ TraefikAPI = (*MockAPIClient)(nil) +var _ TraefikAPI = (*Client)(nil) \ No newline at end of file diff --git a/backend/internal/traefik/types.go b/backend/internal/traefik/types.go new file mode 100644 index 0000000..4360a29 --- /dev/null +++ b/backend/internal/traefik/types.go @@ -0,0 +1,291 @@ +package traefik + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" +) + +// TraefikAPI is the typed v3.7 API interface listing all Get* methods +type TraefikAPI interface { + GetHealthz(ctx context.Context) (*HealthResponse, error) + GetOverview(ctx context.Context) (*Overview, error) + GetEntrypoints(ctx context.Context) ([]Entrypoint, error) + GetRouters(ctx context.Context) ([]Router, error) + GetServices(ctx context.Context) ([]Service, error) + GetMiddlewares(ctx context.Context) ([]Middleware, error) + GetProviders(ctx context.Context) ([]Provider, error) + GetCertificates(ctx context.Context) ([]Certificate, error) +} + +// HealthResponse matches GET /api/healthz +type HealthResponse struct { + Status string `json:"status,omitempty"` + Version string `json:"version,omitempty"` + Msg string `json:"msg,omitempty"` +} + +// Entrypoint matches GET /api/entrypoints item +type Entrypoint struct { + Name string `json:"name"` + Address string `json:"address"` + ForwardedHeaders *ForwardedHeaders `json:"forwardedHeaders,omitempty"` + HTTP *EntrypointHTTP `json:"http,omitempty"` + Transport *EntrypointTransport `json:"transport,omitempty"` +} + +type ForwardedHeaders struct { + Insecure bool `json:"insecure,omitempty"` + TrustedIPs []string `json:"trustedIPs,omitempty"` +} + +type EntrypointHTTP struct { + Address string `json:"address,omitempty"` + Middlewares []string `json:"middlewares,omitempty"` + TLS *EntrypointTLS `json:"tls,omitempty"` +} + +type EntrypointTLS struct { + Options string `json:"options,omitempty"` + CertResolver string `json:"certResolver,omitempty"` +} + +type EntrypointTransport struct { + LifeCycle *TransportLifeCycle `json:"lifeCycle,omitempty"` + RespondingTimeouts *TransportRespondingTimeouts `json:"respondingTimeouts,omitempty"` +} + +type TransportLifeCycle struct { + GraceTimeOut string `json:"graceTimeOut,omitempty"` +} +type TransportRespondingTimeouts struct { + IdleTimeout string `json:"idleTimeout,omitempty"` +} + +// Router matches GET /api/routers and /api/http/routers etc. +type Router struct { + Name string `json:"name"` + Provider string `json:"provider"` + EntryPoints []string `json:"entryPoints"` + Service string `json:"service"` + Rule string `json:"rule"` + Priority int `json:"priority,omitempty"` + Status string `json:"status"` + Using []string `json:"using,omitempty"` + Middlewares []string `json:"middlewares,omitempty"` + TLS *RouterTLS `json:"tls,omitempty"` + Err string `json:"err,omitempty"` +} + +type RouterTLS struct { + Options string `json:"options,omitempty"` + CertResolver string `json:"certResolver,omitempty"` + Domains []Domain `json:"domains,omitempty"` +} + +type Domain struct { + Main string `json:"main"` + SANs []string `json:"sans,omitempty"` +} + +// Service matches GET /api/services /api/http/services +// v3.7 adds service-level middlewares for HTTP services +type Service struct { + Name string `json:"name"` + Provider string `json:"provider"` + Type string `json:"type,omitempty"` + Status string `json:"status"` + ServerStatus map[string]string `json:"serverStatus,omitempty"` + LoadBalancer *LoadBalancer `json:"loadBalancer,omitempty"` + Weighted *Weighted `json:"weighted,omitempty"` + Mirroring *Mirroring `json:"mirroring,omitempty"` + // v3.7 new: service-level middlewares + Middlewares []string `json:"middlewares,omitempty"` + Err string `json:"err,omitempty"` +} + +type LoadBalancer struct { + Servers []Server `json:"servers,omitempty"` + PassHostHeader *bool `json:"passHostHeader,omitempty"` + Strategy string `json:"strategy,omitempty"` +} + +type Server struct { + URL string `json:"url"` + Weight *int `json:"weight,omitempty"` +} + +type Weighted struct { + Services []WeightedService `json:"services,omitempty"` +} + +type WeightedService struct { + Name string `json:"name"` + Weight *int `json:"weight,omitempty"` +} + +type Mirroring struct { + Service string `json:"service"` + Mirrors []MirrorService `json:"mirrors,omitempty"` +} + +type MirrorService struct { + Name string `json:"name"` + Percent int `json:"percent,omitempty"` +} + +// Middleware matches GET /api/middlewares +type Middleware struct { + Name string `json:"name"` + Provider string `json:"provider"` + Type string `json:"type,omitempty"` + Status string `json:"status"` + Err string `json:"err,omitempty"` + Using []string `json:"using,omitempty"` +} + +// Certificate matches GET /api/certificates +// v3.7 fields: names, notAfter, issuer, sans (plus store, subject, serialNumber, notBefore) +type Certificate struct { + Store string `json:"store,omitempty"` + Names []string `json:"names,omitempty"` + SANs []string `json:"sans,omitempty"` + Issuer string `json:"issuer,omitempty"` + Subject string `json:"subject,omitempty"` + SerialNumber string `json:"serialNumber,omitempty"` + NotAfter time.Time `json:"notAfter"` + NotBefore time.Time `json:"notBefore"` + // Domains alternative representation + Domains []string `json:"domains,omitempty"` +} + +// custom Unmarshal for Certificate to handle issuer as string or object and time formats +func (c *Certificate) UnmarshalJSON(data []byte) error { + // Use helper with RawMessage for flexible fields + var helper struct { + Store string `json:"store"` + Names []string `json:"names"` + SANs []string `json:"sans"` + IssuerRaw json.RawMessage `json:"issuer"` + Subject string `json:"subject"` + SerialNumber string `json:"serialNumber"` + NotAfter time.Time `json:"notAfter"` + NotBefore time.Time `json:"notBefore"` + DomainsRaw json.RawMessage `json:"domains"` + // legacy snake_case fallback + LegacyNotAfter *time.Time `json:"not_after"` + LegacyNotBefore *time.Time `json:"not_before"` + } + if err := json.Unmarshal(data, &helper); err != nil { + return fmt.Errorf("invalid certificate json: %w", err) + } + c.Store = helper.Store + c.Names = helper.Names + c.SANs = helper.SANs + c.Subject = helper.Subject + c.SerialNumber = helper.SerialNumber + c.NotAfter = helper.NotAfter + c.NotBefore = helper.NotBefore + if helper.LegacyNotAfter != nil && c.NotAfter.IsZero() { + c.NotAfter = *helper.LegacyNotAfter + } + if helper.LegacyNotBefore != nil && c.NotBefore.IsZero() { + c.NotBefore = *helper.LegacyNotBefore + } + // Issuer can be string or object + if len(helper.IssuerRaw) > 0 { + var s string + if err := json.Unmarshal(helper.IssuerRaw, &s); err == nil { + c.Issuer = s + } else { + var obj map[string]interface{} + if err := json.Unmarshal(helper.IssuerRaw, &obj); err == nil { + if cn, ok := obj["commonName"]; ok { + c.Issuer = fmt.Sprint(cn) + } else if org, ok := obj["organization"]; ok { + c.Issuer = fmt.Sprint(org) + } else if cn, ok := obj["CN"]; ok { + c.Issuer = fmt.Sprint(cn) + } else { + // fallback to raw + c.Issuer = strings.Trim(string(helper.IssuerRaw), `"`) + } + } + } + } + // Domains handling: could be object {main, sans} or []string + if len(helper.DomainsRaw) > 0 && len(c.Names) == 0 { + var dom struct { + Main string `json:"main"` + Sans []string `json:"sans"` + } + if err := json.Unmarshal(helper.DomainsRaw, &dom); err == nil && dom.Main != "" { + c.Names = append([]string{dom.Main}, dom.Sans...) + } else { + var list []string + if err := json.Unmarshal(helper.DomainsRaw, &list); err == nil { + c.Names = list + } + } + } + if len(c.Names) == 0 && len(c.SANs) > 0 { + c.Names = c.SANs + } + if len(c.SANs) == 0 && len(c.Names) > 0 { + c.SANs = c.Names + } + return nil +} + +// Provider matches GET /api/providers +type Provider struct { + Name string `json:"name"` +} + +func (p *Provider) UnmarshalJSON(data []byte) error { + // Try string + var s string + if err := json.Unmarshal(data, &s); err == nil { + p.Name = s + return nil + } + // Try object + var obj struct { + Name string `json:"name"` + } + if err := json.Unmarshal(data, &obj); err == nil { + p.Name = obj.Name + return nil + } + return fmt.Errorf("invalid provider json") +} + +// helper to normalize provider list +func parseProviders(data []byte) ([]Provider, error) { + // Try []string + var strs []string + if err := json.Unmarshal(data, &strs); err == nil { + out := make([]Provider, len(strs)) + for i, s := range strs { + out[i] = Provider{Name: strings.TrimSpace(s)} + } + return out, nil + } + // Try []Provider + var providers []Provider + if err := json.Unmarshal(data, &providers); err == nil { + return providers, nil + } + // Try map + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err == nil { + out := make([]Provider, 0, len(m)) + for k := range m { + out = append(out, Provider{Name: k}) + } + return out, nil + } + return nil, fmt.Errorf("invalid providers json") +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0b27fb9..7464f06 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,9 @@ "zustand": "^4.5.0" }, "devDependencies": { + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", "@typescript-eslint/eslint-plugin": "^6.19.0", @@ -26,8 +29,70 @@ "eslint": "^8.56.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", + "jsdom": "^30.0.1", "typescript": "^5.3.3", - "vite": "^5.0.11" + "vite": "^5.0.11", + "vitest": "^4.1.11" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, "node_modules/@babel/code-frame": { @@ -284,6 +349,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -332,6 +407,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz", + "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.12.tgz", + "integrity": "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -621,6 +849,24 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -638,6 +884,24 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -655,6 +919,24 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -810,6 +1092,24 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -977,6 +1277,16 @@ "node": ">= 8" } }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, "node_modules/@remix-run/router": { "version": "1.23.4", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", @@ -986,6 +1296,261 @@ "node": ">=14.0.0" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1343,6 +1908,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tanstack/query-core": { "version": "5.102.8", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz", @@ -1369,6 +1941,113 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1414,6 +2093,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1689,6 +2386,92 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -1774,6 +2557,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -1784,6 +2577,16 @@ "node": ">=8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1822,6 +2625,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", @@ -1923,6 +2736,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2001,6 +2824,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2008,6 +2852,35 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/date-fns": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", @@ -2035,6 +2908,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2051,6 +2931,26 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2077,6 +2977,14 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2098,6 +3006,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2116,6 +3037,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -2393,6 +3321,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2403,6 +3341,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2807,6 +3755,19 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -2857,6 +3818,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -2919,6 +3890,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2955,6 +3933,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3026,6 +4055,267 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3080,6 +4370,27 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3089,6 +4400,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3134,6 +4452,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", @@ -3192,6 +4520,20 @@ "node": ">=18" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3265,6 +4607,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3305,6 +4660,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3364,6 +4726,36 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -3429,6 +4821,14 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3471,6 +4871,30 @@ "react-dom": ">=16.8" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3509,6 +4933,47 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rolldown": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.148.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", @@ -3579,6 +5044,19 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -3624,6 +5102,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -3644,6 +5129,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -3657,6 +5156,19 @@ "node": ">=8" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3683,6 +5195,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -3690,6 +5209,101 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz", + "integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.11" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz", + "integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3703,6 +5317,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -3756,6 +5396,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.10.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.1.tgz", + "integrity": "sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", @@ -3866,6 +5516,720 @@ } } }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3882,6 +6246,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3899,6 +6280,23 @@ "dev": true, "license": "ISC" }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index a11fb13..6043dc4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,9 @@ "build": "vite build", "preview": "vite preview", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "build:check": "tsc --noEmit && vite build" + "build:check": "tsc --noEmit && vite build", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "react": "^18.2.0", @@ -21,6 +23,9 @@ "lucide-react": "^0.309.0" }, "devDependencies": { + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", "@typescript-eslint/eslint-plugin": "^6.19.0", @@ -29,7 +34,9 @@ "eslint": "^8.56.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", + "jsdom": "^30.0.1", "typescript": "^5.3.3", - "vite": "^5.0.11" + "vite": "^5.0.11", + "vitest": "^4.1.11" } } \ No newline at end of file diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts new file mode 100644 index 0000000..c126319 --- /dev/null +++ b/frontend/src/api/client.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setCSRFToken, getCSRFToken, api } from './client' + +// Mock axios create to capture interceptors +describe('api client CSRF', () => { + beforeEach(() => { + setCSRFToken('') + }) + + it('stores and retrieves CSRF token', async () => { + setCSRFToken('abc123') + expect(getCSRFToken()).toBe('abc123') + }) + + it('clears token on logout', () => { + setCSRFToken('token') + setCSRFToken('') + expect(getCSRFToken()).toBe('') + }) + + it('does not expose token in localStorage', () => { + setCSRFToken('secret') + expect(localStorage.getItem('csrf')).toBeNull() + expect(localStorage.getItem('csrf_token')).toBeNull() + expect(sessionStorage.getItem('csrf')).toBeNull() + }) + + it('api instance has baseURL and withCredentials', () => { + expect(api.defaults.baseURL).toBeDefined() + expect(api.defaults.withCredentials).toBe(true) + }) +}) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e110fe9..066d4ea 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -49,7 +49,10 @@ export const authApi = { api.post('/auth/logout'), me: () => - api.get('/auth/me'), + api.get('/auth/me'), + + csrf: () => + api.get<{ csrf_token: string }>('/auth/csrf'), } // Config API diff --git a/frontend/src/api/traefik.ts b/frontend/src/api/traefik.ts new file mode 100644 index 0000000..afb2c4b --- /dev/null +++ b/frontend/src/api/traefik.ts @@ -0,0 +1,71 @@ +import { api } from './client' + +// Exact JSON shapes the backend sends via TraefikAPIHandler + +export interface TraefikCertificate { + store?: string + names?: string[] + sans?: string[] + issuer?: string + subject?: string + serialNumber?: string + notBefore: string + notAfter: string + // server-computed + days_until_expiry: number + expired: boolean + // also embedded raw fields from traefik.Certificate may include domains etc., but we capture main ones +} + +export interface TraefikOverview { + http?: unknown + tcp?: unknown + udp?: unknown + providers?: string[] + certificates?: unknown + totalRouters: number + totalServices: number + totalMiddlewares: number + traefikVersion: string + traefikCodename: string +} + +export interface TraefikHealthRaw { + status?: string + version?: string + msg?: string +} + +export interface TraefikHealth { + healthy: boolean + raw: TraefikHealthRaw +} + +export interface TraefikProvider { + name: string +} + +// Typed functions via existing axios client (CSRF handled automatically for POST; GET needs no CSRF but we include headers anyway) +export function getCertificates(): Promise { + return api.get('/traefik/certificates').then(r => r.data) +} + +export function getOverview(): Promise { + return api.get('/traefik/overview').then(r => r.data) +} + +export function getHealth(): Promise { + return api.get('/traefik/health').then(r => r.data) +} + +export function getProviders(): Promise { + return api.get('/traefik/providers').then(r => r.data) +} + +// Keep legacy names for compatibility if needed +export const traefikClient = { + getCertificates, + getOverview, + getHealth, + getProviders, +} diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index af7757c..2db2e81 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -12,15 +12,23 @@ import SettingsPage from '../../pages/Settings' import ConfigEditor from '../../pages/ConfigEditor' const navigation = [ - { name: 'Dashboard', href: '/dashboard', icon: Home }, - { name: 'Config Editor', href: '/config', icon: FileText }, - { name: 'Routers', href: '/routers', icon: Network }, - { name: 'Services', href: '/services', icon: Server }, - { name: 'Middlewares', href: '/middlewares', icon: Shield }, - { name: 'Certificates', href: '/certificates', icon: Settings }, - { name: 'Settings', href: '/settings', icon: Settings }, + { name: 'Dashboard', href: '/dashboard', icon: Home, roles: ['viewer', 'operator', 'admin'] }, + { name: 'Config Editor', href: '/config', icon: FileText, roles: ['viewer', 'operator', 'admin'] }, + { name: 'Routers', href: '/routers', icon: Network, roles: ['viewer', 'operator', 'admin'] }, + { name: 'Services', href: '/services', icon: Server, roles: ['viewer', 'operator', 'admin'] }, + { name: 'Middlewares', href: '/middlewares', icon: Shield, roles: ['viewer', 'operator', 'admin'] }, + { name: 'Certificates', href: '/certificates', icon: Shield, roles: ['viewer', 'operator', 'admin'] }, + { name: 'Settings', href: '/settings', icon: Settings, roles: ['admin', 'operator', 'viewer'] }, ] +function RequireViewer({ children }: { children: React.ReactNode }) { + const { user } = useAuth() + if (!user || !['viewer', 'operator', 'admin'].includes(user.role)) { + return
Forbidden: viewer role required
+ } + return <>{children} +} + export default function Layout() { const { user, logout } = useAuth() const [sidebarOpen, setSidebarOpen] = useState(false) @@ -34,7 +42,7 @@ export default function Layout() {