package router import ( "net/http" "net/http/httptest" "strings" "testing" "time" "hyapp-admin-server/internal/config" "hyapp-admin-server/internal/modules/appuser" "hyapp-admin-server/internal/modules/externaladmin" "hyapp-admin-server/internal/modules/opscenter" "hyapp-admin-server/internal/service" "github.com/gin-gonic/gin" ) func TestOpsCenterAppBootstrapDoesNotRequireAppCode(t *testing.T) { gin.SetMode(gin.TestMode) auth := service.NewAuthService("ops-center-router-test-secret", time.Hour) token, _, err := auth.GenerateAccessToken(7, "operator", nil) if err != nil { t.Fatalf("generate access token: %v", err) } engine := New(config.Config{}, auth, nil, Handlers{OpsCenter: opscenter.New(nil, nil)}) request := httptest.NewRequest(http.MethodGet, "/api/v1/admin/ops-center/apps", nil) request.Header.Set("Authorization", "Bearer "+token) response := httptest.NewRecorder() engine.ServeHTTP(response, request) // Ops Center 先读取跨 App 目录,再按 app_code 查询每个应用的数据;启动接口若要求 // X-App-Code,就会形成“先拿到 App 才能发 header、先发 header 才能拿 App”的死锁。 if response.Code != http.StatusOK { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } if body := response.Body.String(); !strings.Contains(body, `"app_code":"yumi"`) || !strings.Contains(body, `"app_code":"aslan"`) { t.Fatalf("bootstrap app catalog missing fixed apps: %s", body) } // 从 appProtected 移出只解除 App 选择依赖;路由仍必须位于 protected 下,不能变成匿名应用目录。 unauthorized := httptest.NewRecorder() engine.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/api/v1/admin/ops-center/apps", nil)) if unauthorized.Code != http.StatusUnauthorized { t.Fatalf("unauthorized status = %d body=%s", unauthorized.Code, unauthorized.Body.String()) } } func TestExternalPortalRegistersOnlyExplicitAppUserWhitelist(t *testing.T) { gin.SetMode(gin.TestMode) auth := service.NewAuthService("external-router-test-secret", time.Hour) externalHandler := externaladmin.New(nil, nil, externaladmin.Config{}, nil) engine := New(config.Config{}, auth, nil, Handlers{ ExternalAdmin: externalHandler, AppUser: &appuser.Handler{}, }) // The listed route exists and is protected by the isolated opaque session middleware. allowed := httptest.NewRecorder() engine.ServeHTTP(allowed, httptest.NewRequest(http.MethodGet, "/api/v1/external/app/users", nil)) if allowed.Code != http.StatusUnauthorized { t.Fatalf("whitelisted route status = %d body=%s", allowed.Code, allowed.Body.String()) } team := httptest.NewRecorder() engine.ServeHTTP(team, httptest.NewRequest(http.MethodGet, "/api/v1/external/admin/my-team/agencies", nil)) if team.Code != http.StatusUnauthorized { t.Fatalf("my-team route must exist behind external session auth: status=%d body=%s", team.Code, team.Body.String()) } for _, path := range []string{ "/api/v1/external/app/users/1/password", "/api/v1/external/app/users/1/access-token", "/api/v1/external/exports/app-users", } { request := httptest.NewRequest(http.MethodPost, path, nil) response := httptest.NewRecorder() engine.ServeHTTP(response, request) if response.Code != http.StatusNotFound { t.Fatalf("non-whitelisted route %s status = %d body=%s", path, response.Code, response.Body.String()) } } } func TestRouterDoesNotTrustForwardedIPFromDirectClient(t *testing.T) { gin.SetMode(gin.TestMode) engine := New(config.Config{TrustedProxies: []string{"127.0.0.0/8", "::1"}}, nil, nil, Handlers{}) engine.GET("/__test_client_ip", func(c *gin.Context) { c.String(http.StatusOK, c.ClientIP()) }) // A direct caller is outside the configured ingress hop. Its XFF value must be // ignored, otherwise it can rotate a forged address to bypass external-login limits. directRequest := httptest.NewRequest(http.MethodGet, "/__test_client_ip", nil) directRequest.RemoteAddr = "198.51.100.24:43210" directRequest.Header.Set("X-Forwarded-For", "203.0.113.99") directResponse := httptest.NewRecorder() engine.ServeHTTP(directResponse, directRequest) if got := directResponse.Body.String(); got != "198.51.100.24" { t.Fatalf("direct client IP = %q, want socket peer", got) } // The local Nginx hop is trusted, but Gin must stop at the right-most untrusted // address. A prepended attacker-controlled value therefore cannot become identity. proxiedRequest := httptest.NewRequest(http.MethodGet, "/__test_client_ip", nil) proxiedRequest.RemoteAddr = "127.0.0.1:43210" proxiedRequest.Header.Set("X-Forwarded-For", "203.0.113.99, 198.51.100.24") proxiedResponse := httptest.NewRecorder() engine.ServeHTTP(proxiedResponse, proxiedRequest) if got := proxiedResponse.Body.String(); got != "198.51.100.24" { t.Fatalf("proxied client IP = %q, want right-most untrusted hop", got) } }