diff --git a/backend/plugins/domain/system/health_route_test.go b/backend/plugins/domain/system/health_route_test.go new file mode 100644 index 00000000..a0e08e9a --- /dev/null +++ b/backend/plugins/domain/system/health_route_test.go @@ -0,0 +1,97 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package system + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "Wavelet/core" + + "github.com/gin-gonic/gin" +) + +func TestHealthRouteReturnsOKNil(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := core.NewContext(context.Background()) + if err := New().Apply(ctx); err != nil { + t.Fatal(err) + } + + healthHandler := routeHandler(t, ctx, "GET", "/api/health") + routeHandler(t, ctx, "GET", "/healthz") + if !ctx.Router().IsWhitelisted("/api/health") { + t.Fatal("GET /api/health is not whitelisted") + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/health", nil) + healthHandler(c) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + + var body struct { + ErrorMsg string `json:"error_msg"` + Data any `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.ErrorMsg != "" { + t.Fatalf("error_msg = %q, want empty", body.ErrorMsg) + } + if body.Data != nil { + t.Fatalf("data = %#v, want null", body.Data) + } +} + +func TestHealthzRouteUnchanged(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := core.NewContext(context.Background()) + if err := New().Apply(ctx); err != nil { + t.Fatal(err) + } + + handler := routeHandler(t, ctx, "GET", "/healthz") + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/healthz", nil) + handler(c) + + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["status"] != "ok" { + t.Fatalf("body = %s, want {status: ok}", w.Body.String()) + } +} + +func routeHandler(t *testing.T, ctx *core.Context, method, path string) gin.HandlerFunc { + t.Helper() + for _, rd := range ctx.Router().Routes() { + if rd.Method != method || rd.Path != path { + continue + } + if len(rd.Handlers) == 0 { + t.Fatalf("%s %s has no handlers", method, path) + } + switch h := rd.Handlers[0].(type) { + case gin.HandlerFunc: + return h + case func(*gin.Context): + return h + default: + t.Fatalf("unexpected handler type %T", rd.Handlers[0]) + } + } + t.Fatalf("%s %s missing", method, path) + return nil +} diff --git a/backend/plugins/domain/system/plugin.go b/backend/plugins/domain/system/plugin.go index 5d171943..5e636967 100644 --- a/backend/plugins/domain/system/plugin.go +++ b/backend/plugins/domain/system/plugin.go @@ -56,6 +56,10 @@ func (p *Plugin) Apply(ctx *core.Context) error { ctx.Router().GET("/api/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) + ctx.Router().GET("/api/health", func(c *gin.Context) { + c.JSON(http.StatusOK, response.OKNil()) + }) + ctx.Router().RegisterWhitelist("/api/health") // 2. Public config ctx.Router().GET("/api/v1/config/public", func(c *gin.Context) { diff --git a/backend/plugins/domain/user/handlers.go b/backend/plugins/domain/user/handlers.go index 2db068c2..198f9431 100644 --- a/backend/plugins/domain/user/handlers.go +++ b/backend/plugins/domain/user/handlers.go @@ -200,6 +200,22 @@ func ChangePassword(c *gin.Context) { c.JSON(http.StatusOK, response.OKNil()) } +// Self returns the current authenticated user. +func Self(c *gin.Context) { + svc := getAuthService() + if svc == nil { + response.AbortUnauthorized(c, errUserNotFound) + return + } + user, err := svc.GetCurrentUser(c) + if err != nil { + logger.ErrorF(c.Request.Context(), "get current user failed: %v", err) + response.AbortUnauthorized(c, errUserNotFound) + return + } + c.JSON(http.StatusOK, response.OK(user)) +} + // UpdateProfile updates profile info. func UpdateProfile(c *gin.Context) { var req updateProfileRequest diff --git a/backend/plugins/domain/user/plugin.go b/backend/plugins/domain/user/plugin.go index ba5ce590..a46c37b4 100644 --- a/backend/plugins/domain/user/plugin.go +++ b/backend/plugins/domain/user/plugin.go @@ -142,6 +142,7 @@ func (p *Plugin) Apply(ctx *core.Context) error { userGroup.GET("/logout", Logout) userGroup.POST("/send-email-code", emailCap, SendEmailCode) userGroup.POST("/change-password", loginMW, ChangePassword) + userGroup.GET("/self", loginMW, Self) userGroup.PUT("/profile", loginMW, UpdateProfile) // Access Tokens diff --git a/backend/plugins/domain/user/self_route_test.go b/backend/plugins/domain/user/self_route_test.go new file mode 100644 index 00000000..34038206 --- /dev/null +++ b/backend/plugins/domain/user/self_route_test.go @@ -0,0 +1,45 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package user_test + +import ( + "context" + "testing" + + "Wavelet/core" + "Wavelet/core/extpoints" + "Wavelet/plugins/domain/user" +) + +func TestSelfRouteRegisteredWithLoginProtection(t *testing.T) { + ctx := core.NewContext(context.Background()) + if err := user.New().Apply(ctx); err != nil { + t.Fatal(err) + } + + routes := ctx.Router().Routes() + self, ok := findRoute(routes, "GET", "/api/v1/user/self") + if !ok { + t.Fatal("GET /api/v1/user/self missing") + } + profile, ok := findRoute(routes, "PUT", "/api/v1/user/profile") + if !ok { + t.Fatal("PUT /api/v1/user/profile missing") + } + + selfCount := len(self.Handlers) + len(self.Middlewares) + profileCount := len(profile.Handlers) + len(profile.Middlewares) + if selfCount < profileCount { + t.Fatalf("GET /api/v1/user/self handler/middleware count = %d, want >= %d (profile)", selfCount, profileCount) + } +} + +func findRoute(routes []extpoints.RouteDefinition, method, path string) (extpoints.RouteDefinition, bool) { + for _, rd := range routes { + if rd.Method == method && rd.Path == path { + return rd, true + } + } + return extpoints.RouteDefinition{}, false +}