package server import ( "bytes" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "sort" "strings" "testing" "kra/internal/conf" "kra/internal/server/handler" "github.com/gin-gonic/gin" ) func emptyHandlers() *handler.Set { return &handler.Set{ Authority: &handler.Authority{}, Menu: &handler.Menu{}, API: &handler.API{}, Permission: &handler.Permission{}, Organization: &handler.Organization{}, Announcement: &handler.Announcement{}, Email: &handler.Email{}, Payment: &handler.Payment{}, Task: &handler.Task{}, Media: &handler.Media{}, Audit: &handler.Audit{}, Export: &handler.Export{}, Version: &handler.Version{}, Dictionary: &handler.Dictionary{}, Parameter: &handler.Parameter{}, APIToken: &handler.APIToken{}, SystemConfig: &handler.SystemConfig{}, Public: &handler.Public{}, User: &handler.User{}, Navigation: &handler.Navigation{}, Session: &handler.Session{}, IntegrationConfig: &handler.IntegrationConfig{}, } } func TestGinRouteContract(t *testing.T) { engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "test") routes := engine.Routes() actual := make([]string, 0, len(routes)) seen := make(map[string]struct{}, len(routes)) for _, route := range routes { key := route.Method + " " + route.Path if _, exists := seen[key]; exists { t.Fatalf("duplicate route %s", key) } seen[key] = struct{}{} actual = append(actual, key) } sort.Strings(actual) value := strings.Join(actual, "\n") for _, route := range []string{ "GET /integration/configs/:kind", "GET /integration/configs/:kind/:provider", "PUT /integration/configs/:kind/:provider", "DELETE /integration/configs/:kind/:provider", "POST /payment/providers/:provider/test", } { if !strings.Contains(value, route) { t.Fatalf("route contract missing %s:\n%s", route, value) } } for _, removed := range []string{"GET /payment/configs", "POST /payment/config"} { if strings.Contains(value, removed) { t.Fatalf("legacy payment config route still registered: %s", removed) } } } func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) { var output bytes.Buffer logger := slog.New(slog.NewJSONHandler(&output, nil)) engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, logger, "test") text := output.String() if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want { t.Fatalf("registered route log count = %d, want %d", got, want) } if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":193`) { t.Fatalf("startup route summary is missing: %s", text) } } func TestSwaggerUsesRegisteredGinRoutes(t *testing.T) { engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0") response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil) engine.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("swagger document status = %d, body=%s", response.Code, response.Body.String()) } body := response.Body.String() for _, expected := range []string{`"swagger":"2.0"`, `"version":"v1.0.0"`, `"/base/login"`, `"/timedTask/triggerTimedTask"`, `"/mediaUpload/{uploadId}"`} { if !strings.Contains(body, expected) { t.Fatalf("swagger document missing %s", expected) } } } func TestSwaggerSupportsRouterPrefix(t *testing.T) { engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{RouterPrefix: "/admin"}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0") for _, path := range []string{"/admin/swagger/index.html", "/admin/swagger/doc.json"} { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, path, nil) engine.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("swagger path %s status = %d, body=%s", path, response.Code, response.Body.String()) } if strings.HasSuffix(path, "/doc.json") && !strings.Contains(response.Body.String(), `"basePath":"/admin"`) { t.Fatalf("swagger document does not use router prefix: %s", response.Body.String()) } } } func TestSwaggerUsesRootBasePathWithoutRouterPrefix(t *testing.T) { engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0") response := httptest.NewRecorder() engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil)) if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"basePath":"/"`) { t.Fatalf("swagger basePath mismatch: status=%d body=%s", response.Code, response.Body.String()) } } func TestGinDoesNotTrustForwardedIPByDefault(t *testing.T) { engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "test") engine.GET("/client-ip", func(c *gin.Context) { c.String(http.StatusOK, c.ClientIP()) }) request := httptest.NewRequest(http.MethodGet, "/client-ip", nil) request.RemoteAddr = "203.0.113.10:4321" request.Header.Set("X-Forwarded-For", "198.51.100.20") response := httptest.NewRecorder() engine.ServeHTTP(response, request) if response.Code != http.StatusOK || response.Body.String() != "203.0.113.10" { t.Fatalf("client IP = %q, status=%d", response.Body.String(), response.Code) } } func TestAnnouncementDataSourceRequiresAuthentication(t *testing.T) { engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "test") response := httptest.NewRecorder() engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/info/getInfoDataSource", nil)) if response.Code != http.StatusUnauthorized { t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) } } func TestLocalStorageResponseHeaders(t *testing.T) { root := t.TempDir() for name, body := range map[string]string{"script.html": "", "image.png": "png"} { if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o600); err != nil { t.Fatal(err) } } runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: root, PathPrefix: "uploads/file"}, Storage: &conf.AdminBackend_Storage{Type: "local"}}) engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test") for _, test := range []struct { path string attachment bool }{ {path: "/uploads/file/script.html", attachment: true}, {path: "/uploads/file/image.png", attachment: false}, } { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, test.path, nil) engine.ServeHTTP(response, request) if response.Code != http.StatusOK || response.Header().Get("X-Content-Type-Options") != "nosniff" { t.Fatalf("unexpected static response for %s: status=%d headers=%v", test.path, response.Code, response.Header()) } hasAttachment := strings.Contains(response.Header().Get("Content-Disposition"), "attachment") if hasAttachment != test.attachment { t.Fatalf("attachment header for %s = %v, want %v", test.path, hasAttachment, test.attachment) } } } func TestLocalStorageRoutesAreRegistered(t *testing.T) { runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: t.TempDir(), PathPrefix: "uploads/file"}}) engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test") seen := map[string]bool{} for _, route := range engine.Routes() { seen[route.Method+" "+route.Path] = true } for _, expected := range []string{"GET /uploads/file/*filepath", "HEAD /uploads/file/*filepath"} { if !seen[expected] { t.Fatalf("missing registered static route %s", expected) } } response := httptest.NewRecorder() engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil)) if strings.Contains(response.Body.String(), "/uploads/file/") { t.Fatal("swagger document should not expose static file wildcard") } } func TestLocalStorageOldPrefixStopsServingAfterReload(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "image.png"), []byte("png"), 0o600); err != nil { t.Fatal(err) } runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: root, PathPrefix: "uploads/file"}, Storage: &conf.AdminBackend_Storage{Type: "local"}}) engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test") runtime.Replace(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: root, PathPrefix: "files"}, Storage: &conf.AdminBackend_Storage{Type: "local"}}) oldResponse := httptest.NewRecorder() engine.ServeHTTP(oldResponse, httptest.NewRequest(http.MethodGet, "/uploads/file/image.png", nil)) if oldResponse.Code != http.StatusNotFound { t.Fatalf("old static prefix status = %d, want %d", oldResponse.Code, http.StatusNotFound) } newResponse := httptest.NewRecorder() engine.ServeHTTP(newResponse, httptest.NewRequest(http.MethodGet, "/files/image.png", nil)) if newResponse.Code != http.StatusOK { t.Fatalf("new static prefix status = %d, want %d", newResponse.Code, http.StatusOK) } } func TestLocalStorageConflictingPrefixFallsBackWithoutStartupPanic(t *testing.T) { runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: t.TempDir(), PathPrefix: "api"}, Storage: &conf.AdminBackend_Storage{Type: "local"}}) engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test") for _, route := range engine.Routes() { if route.Method == http.MethodGet && route.Path == "/api/*filepath" { t.Fatal("conflicting static wildcard must not be registered") } } } func TestLocalStorageRouteIsNotRegisteredForRemoteStorage(t *testing.T) { runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: t.TempDir(), PathPrefix: "uploads/file"}, Storage: &conf.AdminBackend_Storage{Type: "s3"}}) engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test") for _, route := range engine.Routes() { if strings.HasSuffix(route.Path, "/*filepath") && strings.HasPrefix(route.Path, "/uploads/file/") { t.Fatalf("remote storage registered local route %s %s", route.Method, route.Path) } } } const expectedGinRouteContract = `DELETE /api/deleteApisByIds DELETE /dataAccessLog/deleteDataAccessLogByIds DELETE /department/deleteDepartment DELETE /info/deleteInfo DELETE /info/deleteInfoByIds DELETE /mediaUpload/:uploadId DELETE /position/deletePosition DELETE /sysDictionary/deleteSysDictionary DELETE /sysDictionaryDetail/deleteSysDictionaryDetail DELETE /sysError/deleteSysError DELETE /sysError/deleteSysErrorByIds DELETE /sysExportTemplate/deleteSysExportTemplate DELETE /sysExportTemplate/deleteSysExportTemplateByIds DELETE /sysLoginLog/deleteLoginLog DELETE /sysLoginLog/deleteLoginLogByIds DELETE /sysOperationRecord/deleteSysOperationRecord DELETE /sysOperationRecord/deleteSysOperationRecordByIds DELETE /sysParams/deleteSysParams DELETE /sysParams/deleteSysParamsByIds DELETE /sysVersion/deleteSysVersion DELETE /sysVersion/deleteSysVersionByIds DELETE /timedTask/deleteTimedTask DELETE /user/deleteUser GET /api/freshCasbin GET /api/getApiGroups GET /api/getApiRoles GET /api/syncApi GET /attachmentCategory/getCategoryList GET /authority/getDataScopeDepts GET /authority/getUsersByAuthority GET /department/findDepartment GET /department/getDepartmentUsers GET /fileUploadAndDownload/findFile GET /health GET /info/findInfo GET /info/getInfoDataSource GET /info/getInfoList GET /info/getInfoPublic GET /logViewer/content GET /logViewer/dates GET /logViewer/files GET /menu/getMenuRoles GET /integration/configs/:kind GET /integration/configs/:kind/:provider GET /payment/orders GET /payment/orders/:provider/:tradeNo GET /position/findPosition GET /position/getPositionUsers GET /securityConfig/getSecurityConfig GET /swagger/*any GET /sysDictionary/exportSysDictionary GET /sysDictionary/findSysDictionary GET /sysDictionary/getSysDictionaryList GET /sysDictionary/getSysDictionaryListWithDetails GET /sysDictionaryDetail/findSysDictionaryDetail GET /sysDictionaryDetail/getDictionaryDetailsByParent GET /sysDictionaryDetail/getDictionaryPath GET /sysDictionaryDetail/getDictionaryTreeList GET /sysDictionaryDetail/getDictionaryTreeListByType GET /sysDictionaryDetail/getSysDictionaryDetailList GET /sysError/findSysError GET /sysError/getSysErrorList GET /sysExportTemplate/exportExcel GET /sysExportTemplate/exportExcelByToken GET /sysExportTemplate/exportTemplate GET /sysExportTemplate/exportTemplateByToken GET /sysExportTemplate/findSysExportTemplate GET /sysExportTemplate/getSysExportTemplateList GET /sysExportTemplate/previewSQL GET /sysLoginLog/findLoginLog GET /sysLoginLog/getLoginLogList GET /sysOperationRecord/findSysOperationRecord GET /sysOperationRecord/getSysOperationRecordList GET /sysParams/findSysParams GET /sysParams/getSysParam GET /sysParams/getSysParamsList GET /sysVersion/downloadVersionJson GET /sysVersion/findSysVersion GET /sysVersion/getSysVersionList GET /timedTask/alertStream GET /timedTask/getRegisteredMethods GET /timedTask/getTimedTaskList GET /timedTask/getTimedTaskLogList GET /user/getUserInfo POST /api/createApi POST /api/deleteApi POST /api/enterSyncApi POST /api/getAllApis POST /api/getApiById POST /api/getApiList POST /api/ignoreApi POST /api/setApiRoles POST /api/updateApi POST /attachmentCategory/addCategory POST /attachmentCategory/deleteCategory POST /authority/copyAuthority POST /authority/createAuthority POST /authority/deleteAuthority POST /authority/getAuthorityList POST /authority/setDataScope POST /authority/setRoleUsers POST /authorityBtn/canRemoveAuthorityBtn POST /authorityBtn/getAuthorityBtn POST /authorityBtn/setAuthorityBtn POST /base/captcha POST /base/login POST /casbin/getPolicyPathByAuthorityId POST /casbin/updateCasbin POST /dataAccessLog/getDataAccessLogList POST /department/createDepartment POST /department/getDepartmentList POST /department/setDepartmentUsers POST /email/emailTest POST /email/sendEmail POST /fileUploadAndDownload/deleteFile POST /fileUploadAndDownload/deleteFiles POST /fileUploadAndDownload/editFileName POST /fileUploadAndDownload/getFileList POST /fileUploadAndDownload/importURL POST /fileUploadAndDownload/listOssFiles POST /fileUploadAndDownload/upload POST /info/createInfo POST /init/checkdb POST /init/initdb POST /jwt/jsonInBlacklist POST /mediaUpload/chunk POST /mediaUpload/complete POST /mediaUpload/init POST /menu/addBaseMenu POST /menu/addMenuAuthority POST /menu/deleteBaseMenu POST /menu/getBaseMenuById POST /menu/getBaseMenuTree POST /menu/getMenu POST /menu/getMenuAuthority POST /menu/getMenuList POST /menu/setMenuRoles POST /menu/updateBaseMenu POST /payment/callback/:provider POST /payment/create POST /payment/fulfill POST /payment/order POST /payment/orders/:provider/:tradeNo/fulfill POST /payment/orders/:provider/:tradeNo/refund POST /payment/query POST /payment/refund POST /position/createPosition POST /position/getPositionList POST /position/setPositionUsers POST /securityConfig/setSecurityConfig POST /sysApiToken/createApiToken POST /sysApiToken/deleteApiToken POST /sysApiToken/getApiTokenList POST /sysDictionary/createSysDictionary POST /sysDictionary/importSysDictionary POST /sysDictionaryDetail/createSysDictionaryDetail POST /sysError/createSysError POST /sysExportTemplate/createSysExportTemplate POST /sysExportTemplate/importExcel POST /sysParams/createSysParams POST /sysVersion/exportVersion POST /sysVersion/importVersion POST /system/getServerInfo POST /system/getSystemConfig POST /system/reloadSystem POST /system/setSystemConfig POST /timedTask/createTimedTask POST /timedTask/toggleTimedTask POST /timedTask/triggerTimedTask POST /user/admin_register POST /user/changePassword POST /user/getUserList POST /user/resetPassword POST /user/setUserAuthorities POST /user/setUserAuthority POST /user/setUserDepartments POST /user/setUserPositions PUT /authority/updateAuthority PUT /department/updateDepartment PUT /info/updateInfo PUT /integration/configs/:kind/:provider PUT /position/updatePosition PUT /sysDictionary/updateSysDictionary PUT /sysDictionaryDetail/updateSysDictionaryDetail PUT /sysError/updateSysError PUT /sysExportTemplate/updateSysExportTemplate PUT /sysParams/updateSysParams PUT /timedTask/updateTimedTask PUT /user/setSelfInfo PUT /user/setSelfSetting PUT /user/setUserInfo`