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/repository" "hyapp-admin-server/internal/service" "github.com/DATA-DOG/go-sqlmock" "github.com/gin-gonic/gin" "gorm.io/driver/mysql" "gorm.io/gorm" ) 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) } sqlDB, mock, err := sqlmock.New() if err != nil { t.Fatalf("create sql mock: %v", err) } defer sqlDB.Close() gormDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) if err != nil { t.Fatalf("create gorm db: %v", err) } store := repository.New(gormDB) mock.ExpectQuery("(?s)SELECT admin_users\\.username,.*FROM admin_users.*WHERE admin_users\\.id = \\?.*ORDER BY admin_permissions\\.code ASC"). WithArgs(uint(7)). WillReturnRows(sqlmock.NewRows([]string{"username", "status", "permission_code"}).AddRow("operator", "active", nil)) engine := New(config.Config{}, auth, store, 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()) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("authorization sql expectations: %v", err) } } 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) } }