mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 23:56:37 +08:00
[优化] 改名
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"openflare/controller"
|
||||
"openflare/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SetApiRouter(router *gin.Engine) {
|
||||
apiRouter := router.Group("/api")
|
||||
apiRouter.Use(middleware.GlobalAPIRateLimit())
|
||||
{
|
||||
apiRouter.GET("/status", controller.GetStatus)
|
||||
apiRouter.GET("/notice", controller.GetNotice)
|
||||
apiRouter.GET("/about", controller.GetAbout)
|
||||
apiRouter.GET("/verification", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
|
||||
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
|
||||
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
|
||||
apiRouter.GET("/oauth/github", middleware.CriticalRateLimit(), controller.GitHubOAuth)
|
||||
apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth)
|
||||
apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.WeChatBind)
|
||||
apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.EmailBind)
|
||||
|
||||
userRoute := apiRouter.Group("/user")
|
||||
{
|
||||
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
|
||||
userRoute.POST("/login", middleware.CriticalRateLimit(), controller.Login)
|
||||
userRoute.GET("/logout", controller.Logout)
|
||||
|
||||
selfRoute := userRoute.Group("/")
|
||||
selfRoute.Use(middleware.UserAuth(), middleware.NoTokenAuth())
|
||||
{
|
||||
selfRoute.GET("/self", controller.GetSelf)
|
||||
selfRoute.PUT("/self", controller.UpdateSelf)
|
||||
selfRoute.DELETE("/self", controller.DeleteSelf)
|
||||
selfRoute.GET("/token", controller.GenerateToken)
|
||||
}
|
||||
|
||||
adminRoute := userRoute.Group("/")
|
||||
adminRoute.Use(middleware.AdminAuth(), middleware.NoTokenAuth())
|
||||
{
|
||||
adminRoute.GET("/", controller.GetAllUsers)
|
||||
adminRoute.GET("/search", controller.SearchUsers)
|
||||
adminRoute.GET("/:id", controller.GetUser)
|
||||
adminRoute.POST("/", controller.CreateUser)
|
||||
adminRoute.POST("/manage", controller.ManageUser)
|
||||
adminRoute.PUT("/", controller.UpdateUser)
|
||||
adminRoute.DELETE("/:id", controller.DeleteUser)
|
||||
}
|
||||
}
|
||||
optionRoute := apiRouter.Group("/option")
|
||||
optionRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
|
||||
{
|
||||
optionRoute.GET("/", controller.GetOptions)
|
||||
optionRoute.PUT("/", controller.UpdateOption)
|
||||
optionRoute.POST("/geoip/lookup", controller.LookupGeoIP)
|
||||
}
|
||||
updateRoute := apiRouter.Group("/update")
|
||||
updateRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
|
||||
{
|
||||
updateRoute.GET("/latest-release", controller.GetLatestRelease)
|
||||
updateRoute.GET("/logs/ws", controller.StreamServerUpgradeLogs)
|
||||
updateRoute.POST("/manual-upload", controller.UploadManualServerBinary)
|
||||
updateRoute.POST("/manual-upgrade", controller.ConfirmManualServerUpgrade)
|
||||
updateRoute.POST("/upgrade", controller.UpgradeServer)
|
||||
}
|
||||
fileRoute := apiRouter.Group("/file")
|
||||
fileRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
fileRoute.GET("/", controller.GetAllFiles)
|
||||
fileRoute.GET("/search", controller.SearchFiles)
|
||||
fileRoute.POST("/", middleware.UploadRateLimit(), controller.UploadFile)
|
||||
fileRoute.DELETE("/:id", controller.DeleteFile)
|
||||
}
|
||||
proxyRoute := apiRouter.Group("/proxy-routes")
|
||||
proxyRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
proxyRoute.GET("/", controller.GetProxyRoutes)
|
||||
proxyRoute.POST("/", controller.CreateProxyRoute)
|
||||
proxyRoute.PUT("/:id", controller.UpdateProxyRoute)
|
||||
proxyRoute.DELETE("/:id", controller.DeleteProxyRoute)
|
||||
}
|
||||
managedDomainRoute := apiRouter.Group("/managed-domains")
|
||||
managedDomainRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
managedDomainRoute.GET("/", controller.GetManagedDomains)
|
||||
managedDomainRoute.GET("/match", controller.MatchManagedDomainCertificate)
|
||||
managedDomainRoute.POST("/", controller.CreateManagedDomain)
|
||||
managedDomainRoute.PUT("/:id", controller.UpdateManagedDomain)
|
||||
managedDomainRoute.DELETE("/:id", controller.DeleteManagedDomain)
|
||||
}
|
||||
tlsCertificateRoute := apiRouter.Group("/tls-certificates")
|
||||
tlsCertificateRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
tlsCertificateRoute.GET("/", controller.GetTLSCertificates)
|
||||
tlsCertificateRoute.GET("/:id", controller.GetTLSCertificate)
|
||||
tlsCertificateRoute.GET("/:id/content", controller.GetTLSCertificateContent)
|
||||
tlsCertificateRoute.POST("/", controller.CreateTLSCertificate)
|
||||
tlsCertificateRoute.PUT("/:id", controller.UpdateTLSCertificate)
|
||||
tlsCertificateRoute.POST("/import-file", controller.ImportTLSCertificateFile)
|
||||
tlsCertificateRoute.DELETE("/:id", controller.DeleteTLSCertificate)
|
||||
}
|
||||
configVersionRoute := apiRouter.Group("/config-versions")
|
||||
configVersionRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
configVersionRoute.GET("/", controller.GetConfigVersions)
|
||||
configVersionRoute.GET("/active", controller.GetActiveConfigVersion)
|
||||
configVersionRoute.GET("/preview", controller.PreviewConfigVersion)
|
||||
configVersionRoute.GET("/diff", controller.DiffConfigVersion)
|
||||
configVersionRoute.POST("/publish", controller.PublishConfigVersion)
|
||||
configVersionRoute.PUT("/:id/activate", controller.ActivateConfigVersion)
|
||||
}
|
||||
dashboardRoute := apiRouter.Group("/dashboard")
|
||||
dashboardRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
dashboardRoute.GET("/overview", controller.GetDashboardOverview)
|
||||
}
|
||||
nodeRoute := apiRouter.Group("/nodes")
|
||||
nodeRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
nodeRoute.GET("/bootstrap-token", controller.GetNodeBootstrapToken)
|
||||
nodeRoute.POST("/bootstrap-token/rotate", controller.RotateNodeBootstrapToken)
|
||||
nodeRoute.GET("/", controller.GetNodes)
|
||||
nodeRoute.POST("/", controller.CreateNode)
|
||||
nodeRoute.GET("/:id/agent-release", controller.GetNodeAgentRelease)
|
||||
nodeRoute.GET("/:id/observability", controller.GetNodeObservability)
|
||||
nodeRoute.POST("/:id/agent-update", controller.RequestNodeAgentUpdate)
|
||||
nodeRoute.POST("/:id/openresty-restart", controller.RequestNodeOpenrestyRestart)
|
||||
nodeRoute.PUT("/:id", controller.UpdateNode)
|
||||
nodeRoute.DELETE("/:id", controller.DeleteNode)
|
||||
}
|
||||
applyLogRoute := apiRouter.Group("/apply-logs")
|
||||
applyLogRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
applyLogRoute.GET("/", controller.GetApplyLogs)
|
||||
}
|
||||
accessLogRoute := apiRouter.Group("/access-logs")
|
||||
accessLogRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
accessLogRoute.GET("/", controller.GetAccessLogs)
|
||||
}
|
||||
agentRoute := apiRouter.Group("/agent")
|
||||
{
|
||||
discoveryRoute := agentRoute.Group("/")
|
||||
discoveryRoute.Use(middleware.AgentRegisterAuth())
|
||||
{
|
||||
discoveryRoute.POST("/nodes/register", controller.AgentRegister)
|
||||
}
|
||||
authorizedRoute := agentRoute.Group("/")
|
||||
authorizedRoute.Use(middleware.AgentAuth())
|
||||
{
|
||||
authorizedRoute.POST("/nodes/heartbeat", controller.AgentHeartbeat)
|
||||
authorizedRoute.GET("/config-versions/active", controller.AgentGetActiveConfig)
|
||||
authorizedRoute.POST("/apply-logs", controller.AgentReportApplyLog)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package router_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
"math/big"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
"openflare/router"
|
||||
"openflare/service"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type apiResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func TestPhase1PublishLifecycle(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.RedisEnabled = false
|
||||
setupTestDB(t)
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
|
||||
router.SetApiRouter(engine)
|
||||
|
||||
token := prepareRootToken(t)
|
||||
|
||||
createBody := map[string]any{
|
||||
"domain": "app.example.com",
|
||||
"origin_url": "https://origin-a.internal",
|
||||
"enabled": true,
|
||||
"remark": "primary route",
|
||||
}
|
||||
resp := performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", createBody)
|
||||
var createdRoute model.ProxyRoute
|
||||
decodeResponseData(t, resp, &createdRoute)
|
||||
if createdRoute.Domain != "app.example.com" {
|
||||
t.Fatalf("unexpected created route domain: %s", createdRoute.Domain)
|
||||
}
|
||||
|
||||
resp = performJSONRequest(t, engine, token, http.MethodGet, "/api/proxy-routes/", nil)
|
||||
var routes []model.ProxyRoute
|
||||
decodeResponseData(t, resp, &routes)
|
||||
if len(routes) != 1 {
|
||||
t.Fatalf("expected 1 route, got %d", len(routes))
|
||||
}
|
||||
|
||||
resp = performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil)
|
||||
var version1 model.ConfigVersion
|
||||
decodeResponseData(t, resp, &version1)
|
||||
if !version1.IsActive {
|
||||
t.Fatal("expected published version to be active")
|
||||
}
|
||||
if version1.SnapshotJSON == "" || version1.RenderedConfig == "" || version1.Checksum == "" {
|
||||
t.Fatal("expected published version to contain snapshot, rendered config and checksum")
|
||||
}
|
||||
if version1.MainConfig == "" {
|
||||
t.Fatal("expected published version to contain main config")
|
||||
}
|
||||
|
||||
repeatPublishReq := httptest.NewRequest(http.MethodPost, "/api/config-versions/publish", nil)
|
||||
repeatPublishReq.Header.Set("Authorization", "Bearer "+token)
|
||||
repeatPublishRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(repeatPublishRecorder, repeatPublishReq)
|
||||
if repeatPublishRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status %d for repeated publish: %s", repeatPublishRecorder.Code, repeatPublishRecorder.Body.String())
|
||||
}
|
||||
var repeatPublishResp apiResponse
|
||||
if err := json.Unmarshal(repeatPublishRecorder.Body.Bytes(), &repeatPublishResp); err != nil {
|
||||
t.Fatalf("failed to unmarshal repeated publish response: %v", err)
|
||||
}
|
||||
if repeatPublishResp.Success {
|
||||
t.Fatal("expected repeated publish without route changes to be rejected")
|
||||
}
|
||||
if !strings.Contains(repeatPublishResp.Message, "当前规则没有变更") {
|
||||
t.Fatalf("unexpected repeated publish message: %s", repeatPublishResp.Message)
|
||||
}
|
||||
|
||||
initialSnapshot := version1.SnapshotJSON
|
||||
initialMainConfig := version1.MainConfig
|
||||
initialRendered := version1.RenderedConfig
|
||||
|
||||
updateBody := map[string]any{
|
||||
"domain": "app.example.com",
|
||||
"origin_url": "https://origin-b.internal",
|
||||
"enabled": true,
|
||||
"remark": "updated route",
|
||||
}
|
||||
routePath := "/api/proxy-routes/" + toString(createdRoute.ID)
|
||||
resp = performJSONRequest(t, engine, token, http.MethodPut, routePath, updateBody)
|
||||
decodeResponseData(t, resp, &createdRoute)
|
||||
if createdRoute.OriginURL != "https://origin-b.internal" {
|
||||
t.Fatalf("unexpected updated route origin: %s", createdRoute.OriginURL)
|
||||
}
|
||||
|
||||
resp = performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil)
|
||||
var version2 model.ConfigVersion
|
||||
decodeResponseData(t, resp, &version2)
|
||||
if version2.ID == version1.ID {
|
||||
t.Fatal("expected a new version record")
|
||||
}
|
||||
|
||||
resp = performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/", nil)
|
||||
var versions []model.ConfigVersion
|
||||
decodeResponseData(t, resp, &versions)
|
||||
if len(versions) != 2 {
|
||||
t.Fatalf("expected 2 versions, got %d", len(versions))
|
||||
}
|
||||
|
||||
activeResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/active", nil)
|
||||
var activeVersion model.ConfigVersion
|
||||
decodeResponseData(t, activeResp, &activeVersion)
|
||||
if activeVersion.ID != version2.ID {
|
||||
t.Fatalf("expected version %d active, got %d", version2.ID, activeVersion.ID)
|
||||
}
|
||||
|
||||
activatePath := "/api/config-versions/" + toString(version1.ID) + "/activate"
|
||||
resp = performJSONRequest(t, engine, token, http.MethodPut, activatePath, nil)
|
||||
decodeResponseData(t, resp, &activeVersion)
|
||||
if activeVersion.ID != version1.ID || !activeVersion.IsActive {
|
||||
t.Fatal("expected version1 to become active after rollback activation")
|
||||
}
|
||||
|
||||
var storedVersion1 model.ConfigVersion
|
||||
if err := model.DB.First(&storedVersion1, version1.ID).Error; err != nil {
|
||||
t.Fatalf("failed to query version1: %v", err)
|
||||
}
|
||||
if storedVersion1.SnapshotJSON != initialSnapshot {
|
||||
t.Fatal("expected version1 snapshot to remain immutable")
|
||||
}
|
||||
if storedVersion1.MainConfig != initialMainConfig {
|
||||
t.Fatal("expected version1 main config to remain immutable")
|
||||
}
|
||||
if storedVersion1.RenderedConfig != initialRendered {
|
||||
t.Fatal("expected version1 rendered config to remain immutable")
|
||||
}
|
||||
|
||||
deletePath := "/api/proxy-routes/" + toString(createdRoute.ID)
|
||||
resp = performJSONRequest(t, engine, token, http.MethodDelete, deletePath, nil)
|
||||
if !resp.Success {
|
||||
t.Fatalf("expected delete route success, got: %s", resp.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.RedisEnabled = false
|
||||
setupTestDB(t)
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
|
||||
router.SetApiRouter(engine)
|
||||
|
||||
token := prepareRootToken(t)
|
||||
certPEM, keyPEM := generateCertificatePairForRouterTest(t, []string{"secure.example.com"})
|
||||
|
||||
manualResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/tls-certificates/", map[string]any{
|
||||
"name": "secure-example",
|
||||
"cert_pem": certPEM,
|
||||
"key_pem": keyPEM,
|
||||
"remark": "manual import",
|
||||
})
|
||||
var manualCertificate model.TLSCertificate
|
||||
decodeResponseData(t, manualResp, &manualCertificate)
|
||||
if manualCertificate.ID == 0 {
|
||||
t.Fatal("expected manual certificate import to persist certificate")
|
||||
}
|
||||
|
||||
detailResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/tls-certificates/"+toString(manualCertificate.ID), nil)
|
||||
var certificateDetail map[string]any
|
||||
decodeResponseData(t, detailResp, &certificateDetail)
|
||||
if _, exists := certificateDetail["cert_pem"]; exists {
|
||||
t.Fatal("expected certificate detail endpoint to omit cert_pem")
|
||||
}
|
||||
if _, exists := certificateDetail["key_pem"]; exists {
|
||||
t.Fatal("expected certificate detail endpoint to omit key_pem")
|
||||
}
|
||||
|
||||
contentResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/tls-certificates/"+toString(manualCertificate.ID)+"/content", nil)
|
||||
var certificateContent map[string]any
|
||||
decodeResponseData(t, contentResp, &certificateContent)
|
||||
if certificateContent["cert_pem"] == "" || certificateContent["key_pem"] == "" {
|
||||
t.Fatal("expected certificate content endpoint to return pem payloads")
|
||||
}
|
||||
|
||||
updatedCertPEM, updatedKeyPEM := generateCertificatePairForRouterTest(t, []string{"secure.example.com", "www.secure.example.com"})
|
||||
updateCertificateResp := performJSONRequest(t, engine, token, http.MethodPut, "/api/tls-certificates/"+toString(manualCertificate.ID), map[string]any{
|
||||
"name": "secure-example-updated",
|
||||
"cert_pem": updatedCertPEM,
|
||||
"key_pem": updatedKeyPEM,
|
||||
"remark": "updated manual import",
|
||||
})
|
||||
decodeResponseData(t, updateCertificateResp, &manualCertificate)
|
||||
if manualCertificate.Name != "secure-example-updated" || manualCertificate.Remark != "updated manual import" {
|
||||
t.Fatalf("expected certificate update to persist metadata, got %+v", manualCertificate)
|
||||
}
|
||||
|
||||
fileCertPEM, fileKeyPEM := generateCertificatePairForRouterTest(t, []string{"upload.example.com"})
|
||||
multipartResp := performMultipartRequest(t, engine, token, "/api/tls-certificates/import-file", map[string]string{
|
||||
"name": "upload-example",
|
||||
"remark": "upload import",
|
||||
}, map[string]string{
|
||||
"cert_file": fileCertPEM,
|
||||
"key_file": fileKeyPEM,
|
||||
})
|
||||
var uploadedCertificate model.TLSCertificate
|
||||
decodeResponseData(t, multipartResp, &uploadedCertificate)
|
||||
if uploadedCertificate.ID == 0 {
|
||||
t.Fatal("expected file certificate import to persist certificate")
|
||||
}
|
||||
|
||||
resp := performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", map[string]any{
|
||||
"domain": "secure.example.com",
|
||||
"origin_url": "https://origin-secure.internal",
|
||||
"enabled": true,
|
||||
"enable_https": true,
|
||||
"cert_id": manualCertificate.ID,
|
||||
"redirect_http": true,
|
||||
"remark": "https route",
|
||||
})
|
||||
var route model.ProxyRoute
|
||||
decodeResponseData(t, resp, &route)
|
||||
if !route.EnableHTTPS || route.CertID == nil || *route.CertID != manualCertificate.ID {
|
||||
t.Fatal("expected route to persist https certificate binding")
|
||||
}
|
||||
|
||||
updateResp := performJSONRequest(t, engine, token, http.MethodPut, "/api/proxy-routes/"+toString(route.ID), map[string]any{
|
||||
"domain": "secure.example.com",
|
||||
"origin_url": "http://origin-secure.internal",
|
||||
"enabled": true,
|
||||
"enable_https": false,
|
||||
"cert_id": nil,
|
||||
"redirect_http": false,
|
||||
"remark": "downgraded route",
|
||||
})
|
||||
decodeResponseData(t, updateResp, &route)
|
||||
if route.EnableHTTPS || route.CertID != nil || route.RedirectHTTP {
|
||||
t.Fatalf("expected route to disable https flags, got %+v", route)
|
||||
}
|
||||
|
||||
updateResp = performJSONRequest(t, engine, token, http.MethodPut, "/api/proxy-routes/"+toString(route.ID), map[string]any{
|
||||
"domain": "secure.example.com",
|
||||
"origin_url": "https://origin-secure.internal",
|
||||
"enabled": true,
|
||||
"enable_https": true,
|
||||
"cert_id": manualCertificate.ID,
|
||||
"redirect_http": true,
|
||||
"remark": "re-enabled https route",
|
||||
})
|
||||
decodeResponseData(t, updateResp, &route)
|
||||
if !route.EnableHTTPS || route.CertID == nil || *route.CertID != manualCertificate.ID || !route.RedirectHTTP {
|
||||
t.Fatalf("expected route update to persist https fields, got %+v", route)
|
||||
}
|
||||
|
||||
listResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/proxy-routes/", nil)
|
||||
var routes []model.ProxyRoute
|
||||
decodeResponseData(t, listResp, &routes)
|
||||
if len(routes) != 1 || !routes[0].EnableHTTPS || routes[0].CertID == nil || *routes[0].CertID != manualCertificate.ID || !routes[0].RedirectHTTP {
|
||||
t.Fatalf("expected route list to reflect https update, got %+v", routes)
|
||||
}
|
||||
|
||||
certificateListResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/tls-certificates/", nil)
|
||||
var certificateList []map[string]any
|
||||
decodeResponseData(t, certificateListResp, &certificateList)
|
||||
if len(certificateList) == 0 {
|
||||
t.Fatal("expected certificate list to return records")
|
||||
}
|
||||
if _, exists := certificateList[0]["cert_pem"]; exists {
|
||||
t.Fatal("expected certificate list to omit cert_pem")
|
||||
}
|
||||
if _, exists := certificateList[0]["key_pem"]; exists {
|
||||
t.Fatal("expected certificate list to omit key_pem")
|
||||
}
|
||||
|
||||
resp = performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil)
|
||||
var version model.ConfigVersion
|
||||
decodeResponseData(t, resp, &version)
|
||||
if !strings.Contains(version.MainConfig, "include __OPENFLARE_ROUTE_CONFIG__;") {
|
||||
t.Fatal("expected active config to render managed main config")
|
||||
}
|
||||
if !strings.Contains(version.RenderedConfig, "listen 443 ssl;") {
|
||||
t.Fatal("expected active config to render https listener")
|
||||
}
|
||||
if !strings.Contains(version.RenderedConfig, "return 301 https://$host$request_uri;") {
|
||||
t.Fatal("expected active config to render redirect server")
|
||||
}
|
||||
if !strings.Contains(version.SupportFilesJSON, ".crt") || !strings.Contains(version.SupportFilesJSON, ".key") {
|
||||
t.Fatal("expected support files json to contain certificate artifacts")
|
||||
}
|
||||
if err := (&model.Node{
|
||||
NodeID: "phase1-node",
|
||||
Name: "phase1-node",
|
||||
IP: "10.0.0.8",
|
||||
AgentToken: common.AgentToken,
|
||||
AgentVersion: "0.1.0",
|
||||
NginxVersion: "1.25.5",
|
||||
Status: service.NodeStatusOnline,
|
||||
LastSeenAt: time.Now(),
|
||||
}).Insert(); err != nil {
|
||||
t.Fatalf("failed to seed phase1 node: %v", err)
|
||||
}
|
||||
|
||||
agentResp := performAgentJSONRequestWithToken(t, engine, common.AgentToken, http.MethodGet, "/api/agent/config-versions/active", nil)
|
||||
var activeConfig map[string]any
|
||||
decodeResponseData(t, agentResp, &activeConfig)
|
||||
mainConfig, ok := activeConfig["main_config"].(string)
|
||||
if !ok || !strings.Contains(mainConfig, "include __OPENFLARE_ROUTE_CONFIG__;") {
|
||||
t.Fatalf("expected active config to expose main_config, got %#v", activeConfig["main_config"])
|
||||
}
|
||||
supportFiles, ok := activeConfig["support_files"].([]any)
|
||||
if !ok || len(supportFiles) != 2 {
|
||||
t.Fatalf("expected active config to expose 2 support files, got %#v", activeConfig["support_files"])
|
||||
}
|
||||
}
|
||||
|
||||
func setupTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "phase1.db")
|
||||
common.SQLitePath = dbPath
|
||||
common.AgentToken = "phase1-agent-token"
|
||||
if err := model.InitDB(); err != nil {
|
||||
t.Fatalf("failed to init db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := model.CloseDB(); err != nil {
|
||||
t.Fatalf("failed to close db: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func prepareRootToken(t *testing.T) string {
|
||||
t.Helper()
|
||||
user := &model.User{Username: "root"}
|
||||
if err := user.FillUserByUsername(); err != nil {
|
||||
t.Fatalf("failed to load root user: %v", err)
|
||||
}
|
||||
user.Token = "phase1-test-token"
|
||||
if err := model.DB.Model(user).Update("token", user.Token).Error; err != nil {
|
||||
t.Fatalf("failed to set root token: %v", err)
|
||||
}
|
||||
return user.Token
|
||||
}
|
||||
|
||||
func performJSONRequest(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse {
|
||||
t.Helper()
|
||||
var payload []byte
|
||||
var err error
|
||||
if body != nil {
|
||||
payload, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal request body: %v", err)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(payload))
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status %d for %s %s: %s", recorder.Code, method, path, recorder.Body.String())
|
||||
}
|
||||
var resp apiResponse
|
||||
if err = json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("request %s %s failed: %s", method, path, resp.Message)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func decodeResponseData(t *testing.T, resp apiResponse, target any) {
|
||||
t.Helper()
|
||||
if err := json.Unmarshal(resp.Data, target); err != nil {
|
||||
t.Fatalf("failed to decode response data: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func toString(id uint) string {
|
||||
return strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
|
||||
func performMultipartRequest(t *testing.T, engine http.Handler, token string, path string, fields map[string]string, files map[string]string) apiResponse {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
for key, value := range fields {
|
||||
if err := writer.WriteField(key, value); err != nil {
|
||||
t.Fatalf("failed to write multipart field: %v", err)
|
||||
}
|
||||
}
|
||||
for fieldName, content := range files {
|
||||
part, err := writer.CreateFormFile(fieldName, fieldName+".pem")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create multipart file: %v", err)
|
||||
}
|
||||
if _, err = part.Write([]byte(content)); err != nil {
|
||||
t.Fatalf("failed to write multipart file content: %v", err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, path, &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status %d for multipart %s: %s", recorder.Code, path, recorder.Body.String())
|
||||
}
|
||||
var resp apiResponse
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal multipart response: %v", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("multipart request %s failed: %s", path, resp.Message)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func generateCertificatePairForRouterTest(t *testing.T, dnsNames []string) (string, string) {
|
||||
t.Helper()
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey failed: %v", err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{
|
||||
CommonName: dnsNames[0],
|
||||
},
|
||||
DNSNames: dnsNames,
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificate failed: %v", err)
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})
|
||||
return string(certPEM), string(keyPEM)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package router_test
|
||||
|
||||
import (
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"openflare/common"
|
||||
"openflare/router"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPhase2ManagedDomainLifecycle(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.RedisEnabled = false
|
||||
setupTestDB(t)
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
|
||||
router.SetApiRouter(engine)
|
||||
|
||||
token := prepareRootToken(t)
|
||||
wildcardCertPEM, wildcardKeyPEM := generateCertificatePairForRouterTest(t, []string{"*.example.com"})
|
||||
exactCertPEM, exactKeyPEM := generateCertificatePairForRouterTest(t, []string{"api.example.com"})
|
||||
|
||||
wildcardResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/tls-certificates/", map[string]any{
|
||||
"name": "wildcard-cert",
|
||||
"cert_pem": wildcardCertPEM,
|
||||
"key_pem": wildcardKeyPEM,
|
||||
})
|
||||
var wildcardCertificate map[string]any
|
||||
decodeResponseData(t, wildcardResp, &wildcardCertificate)
|
||||
|
||||
exactResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/tls-certificates/", map[string]any{
|
||||
"name": "exact-cert",
|
||||
"cert_pem": exactCertPEM,
|
||||
"key_pem": exactKeyPEM,
|
||||
})
|
||||
var exactCertificate map[string]any
|
||||
decodeResponseData(t, exactResp, &exactCertificate)
|
||||
|
||||
wildcardID := uint(wildcardCertificate["id"].(float64))
|
||||
exactID := uint(exactCertificate["id"].(float64))
|
||||
|
||||
createWildcard := performJSONRequest(t, engine, token, http.MethodPost, "/api/managed-domains/", map[string]any{
|
||||
"domain": "*.example.com",
|
||||
"cert_id": wildcardID,
|
||||
"enabled": true,
|
||||
"remark": "wildcard binding",
|
||||
})
|
||||
var wildcardDomain map[string]any
|
||||
decodeResponseData(t, createWildcard, &wildcardDomain)
|
||||
|
||||
createExact := performJSONRequest(t, engine, token, http.MethodPost, "/api/managed-domains/", map[string]any{
|
||||
"domain": "api.example.com",
|
||||
"cert_id": exactID,
|
||||
"enabled": true,
|
||||
"remark": "exact binding",
|
||||
})
|
||||
var exactDomain map[string]any
|
||||
decodeResponseData(t, createExact, &exactDomain)
|
||||
|
||||
listResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/managed-domains/", nil)
|
||||
var domains []map[string]any
|
||||
decodeResponseData(t, listResp, &domains)
|
||||
if len(domains) != 2 {
|
||||
t.Fatalf("expected 2 managed domains, got %d", len(domains))
|
||||
}
|
||||
|
||||
matchResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/managed-domains/match?domain=api.example.com", nil)
|
||||
var matchResult map[string]any
|
||||
decodeResponseData(t, matchResp, &matchResult)
|
||||
if matched, ok := matchResult["matched"].(bool); !ok || !matched {
|
||||
t.Fatalf("expected exact domain to be matched, got %#v", matchResult)
|
||||
}
|
||||
candidate, ok := matchResult["candidate"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected candidate payload, got %#v", matchResult["candidate"])
|
||||
}
|
||||
if candidate["match_type"] != "exact" {
|
||||
t.Fatalf("expected exact match type, got %#v", candidate["match_type"])
|
||||
}
|
||||
if uint(candidate["certificate_id"].(float64)) != exactID {
|
||||
t.Fatalf("expected exact certificate id %d, got %#v", exactID, candidate["certificate_id"])
|
||||
}
|
||||
|
||||
updateResp := performJSONRequest(t, engine, token, http.MethodPut, "/api/managed-domains/"+toString(uint(exactDomain["id"].(float64))), map[string]any{
|
||||
"domain": "api.example.com",
|
||||
"cert_id": exactID,
|
||||
"enabled": false,
|
||||
"remark": "disabled exact binding",
|
||||
})
|
||||
decodeResponseData(t, updateResp, &exactDomain)
|
||||
|
||||
matchResp = performJSONRequest(t, engine, token, http.MethodGet, "/api/managed-domains/match?domain=api.example.com", nil)
|
||||
decodeResponseData(t, matchResp, &matchResult)
|
||||
candidate, ok = matchResult["candidate"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected wildcard fallback candidate, got %#v", matchResult["candidate"])
|
||||
}
|
||||
if candidate["match_type"] != "wildcard" {
|
||||
t.Fatalf("expected wildcard fallback, got %#v", candidate["match_type"])
|
||||
}
|
||||
if uint(candidate["certificate_id"].(float64)) != wildcardID {
|
||||
t.Fatalf("expected wildcard certificate id %d, got %#v", wildcardID, candidate["certificate_id"])
|
||||
}
|
||||
|
||||
deleteResp := performJSONRequest(t, engine, token, http.MethodDelete, "/api/managed-domains/"+toString(uint(wildcardDomain["id"].(float64))), nil)
|
||||
if !deleteResp.Success {
|
||||
t.Fatalf("expected delete success, got %s", deleteResp.Message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
package router_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
"openflare/router"
|
||||
"openflare/service"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestPhase2RateLimitOptionsHotReload(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.RedisEnabled = false
|
||||
setupTestDB(t)
|
||||
model.InitOptionMap()
|
||||
|
||||
oldGlobalApiRateLimitNum := common.GlobalApiRateLimitNum
|
||||
oldGlobalApiRateLimitDuration := common.GlobalApiRateLimitDuration
|
||||
oldCriticalRateLimitNum := common.CriticalRateLimitNum
|
||||
oldCriticalRateLimitDuration := common.CriticalRateLimitDuration
|
||||
t.Cleanup(func() {
|
||||
common.GlobalApiRateLimitNum = oldGlobalApiRateLimitNum
|
||||
common.GlobalApiRateLimitDuration = oldGlobalApiRateLimitDuration
|
||||
common.CriticalRateLimitNum = oldCriticalRateLimitNum
|
||||
common.CriticalRateLimitDuration = oldCriticalRateLimitDuration
|
||||
})
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
|
||||
router.SetApiRouter(engine)
|
||||
|
||||
loginCookie := loginAsRoot(t, engine)
|
||||
|
||||
performSessionJSONRequest(t, engine, loginCookie, http.MethodPut, "/api/option/", map[string]any{
|
||||
"key": "GlobalApiRateLimitNum",
|
||||
"value": "450",
|
||||
})
|
||||
performSessionJSONRequest(t, engine, loginCookie, http.MethodPut, "/api/option/", map[string]any{
|
||||
"key": "GlobalApiRateLimitDuration",
|
||||
"value": "240",
|
||||
})
|
||||
performSessionJSONRequest(t, engine, loginCookie, http.MethodPut, "/api/option/", map[string]any{
|
||||
"key": "CriticalRateLimitNum",
|
||||
"value": "150",
|
||||
})
|
||||
performSessionJSONRequest(t, engine, loginCookie, http.MethodPut, "/api/option/", map[string]any{
|
||||
"key": "CriticalRateLimitDuration",
|
||||
"value": "900",
|
||||
})
|
||||
|
||||
if common.GlobalApiRateLimitNum != 450 {
|
||||
t.Fatalf("expected GlobalApiRateLimitNum to be hot reloaded, got %d", common.GlobalApiRateLimitNum)
|
||||
}
|
||||
if common.GlobalApiRateLimitDuration != 240 {
|
||||
t.Fatalf("expected GlobalApiRateLimitDuration to be hot reloaded, got %d", common.GlobalApiRateLimitDuration)
|
||||
}
|
||||
if common.CriticalRateLimitNum != 150 {
|
||||
t.Fatalf("expected CriticalRateLimitNum to be hot reloaded, got %d", common.CriticalRateLimitNum)
|
||||
}
|
||||
if common.CriticalRateLimitDuration != 900 {
|
||||
t.Fatalf("expected CriticalRateLimitDuration to be hot reloaded, got %d", common.CriticalRateLimitDuration)
|
||||
}
|
||||
|
||||
resp := performSessionJSONRequest(t, engine, loginCookie, http.MethodGet, "/api/option/", nil)
|
||||
var options []model.Option
|
||||
decodeResponseData(t, resp, &options)
|
||||
|
||||
optionMap := make(map[string]string, len(options))
|
||||
for _, option := range options {
|
||||
optionMap[option.Key] = option.Value
|
||||
}
|
||||
|
||||
if optionMap["GlobalApiRateLimitNum"] != "450" {
|
||||
t.Fatalf("expected option payload to include GlobalApiRateLimitNum=450, got %q", optionMap["GlobalApiRateLimitNum"])
|
||||
}
|
||||
if optionMap["CriticalRateLimitDuration"] != "900" {
|
||||
t.Fatalf("expected option payload to include CriticalRateLimitDuration=900, got %q", optionMap["CriticalRateLimitDuration"])
|
||||
}
|
||||
}
|
||||
|
||||
func loginAsRoot(t *testing.T, engine http.Handler) *http.Cookie {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"username": "root",
|
||||
"password": "123456",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal login payload: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/user/login", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected login status %d: %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
var resp apiResponse
|
||||
if err = json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode login response: %v", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("root login failed: %s", resp.Message)
|
||||
}
|
||||
|
||||
for _, cookie := range recorder.Result().Cookies() {
|
||||
if cookie.Name == "session" {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
t.Fatal("expected session cookie after root login")
|
||||
return nil
|
||||
}
|
||||
|
||||
func performSessionJSONRequest(t *testing.T, engine http.Handler, sessionCookie *http.Cookie, method string, path string, body any) apiResponse {
|
||||
t.Helper()
|
||||
var payload []byte
|
||||
var err error
|
||||
if body != nil {
|
||||
payload, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal request body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(payload))
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.AddCookie(sessionCookie)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status %d for %s %s: %s", recorder.Code, method, path, recorder.Body.String())
|
||||
}
|
||||
|
||||
var resp apiResponse
|
||||
if err = json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("request %s %s failed: %s", method, path, resp.Message)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.RedisEnabled = false
|
||||
setupTestDB(t)
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
|
||||
router.SetApiRouter(engine)
|
||||
|
||||
adminToken := prepareRootToken(t)
|
||||
|
||||
createRouteAndPublishVersion(t, engine, adminToken)
|
||||
|
||||
dashboardResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/dashboard/overview", nil)
|
||||
var dashboard service.DashboardOverviewView
|
||||
decodeResponseData(t, dashboardResp, &dashboard)
|
||||
if dashboard.Summary.TotalNodes != 0 {
|
||||
t.Fatalf("expected empty dashboard node summary before node registration, got %+v", dashboard.Summary)
|
||||
}
|
||||
|
||||
unauthorizedRequest := httptest.NewRequest(http.MethodPost, "/api/agent/nodes/register", bytes.NewReader([]byte(`{}`)))
|
||||
unauthorizedRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(unauthorizedRecorder, unauthorizedRequest)
|
||||
if unauthorizedRecorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected unauthorized status for missing discovery token, got %d", unauthorizedRecorder.Code)
|
||||
}
|
||||
|
||||
createdNodeResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/", map[string]any{
|
||||
"name": "shanghai-edge-1",
|
||||
"geo_manual_override": true,
|
||||
"geo_name": "Shanghai",
|
||||
"geo_latitude": 31.2304,
|
||||
"geo_longitude": 121.4737,
|
||||
})
|
||||
var createdNode service.NodeView
|
||||
decodeResponseData(t, createdNodeResp, &createdNode)
|
||||
if createdNode.AgentToken == "" || createdNode.Status != service.NodeStatusPending {
|
||||
t.Fatal("expected created node to expose agent token with pending status")
|
||||
}
|
||||
if createdNode.GeoName != "Shanghai" || createdNode.GeoLatitude == nil || createdNode.GeoLongitude == nil {
|
||||
t.Fatalf("expected created node to expose geo metadata, got %+v", createdNode)
|
||||
}
|
||||
|
||||
heartbeatPayload := map[string]any{
|
||||
"node_id": "spoofed-node-id",
|
||||
"name": "shanghai-edge-1",
|
||||
"ip": "10.0.0.9",
|
||||
"agent_version": "0.1.1",
|
||||
"nginx_version": "1.27.1.2",
|
||||
"openresty_status": service.OpenrestyStatusUnhealthy,
|
||||
"openresty_message": "docker run openresty failed: bind 80 already allocated",
|
||||
"current_version": "",
|
||||
"last_error": "",
|
||||
}
|
||||
resp := performAgentJSONRequestWithToken(t, engine, createdNode.AgentToken, http.MethodPost, "/api/agent/nodes/heartbeat", heartbeatPayload)
|
||||
var registeredNode model.Node
|
||||
decodeResponseData(t, resp, ®isteredNode)
|
||||
if registeredNode.IP != "10.0.0.9" || registeredNode.AgentVersion != "0.1.1" || registeredNode.NodeID != createdNode.NodeID {
|
||||
t.Fatal("expected heartbeat to update node metadata")
|
||||
}
|
||||
if registeredNode.OpenrestyStatus != service.OpenrestyStatusUnhealthy {
|
||||
t.Fatal("expected heartbeat to update openresty status")
|
||||
}
|
||||
|
||||
activeConfigResp := performAgentJSONRequestWithToken(t, engine, createdNode.AgentToken, http.MethodGet, "/api/agent/config-versions/active", nil)
|
||||
var activeConfig service.AgentConfigResponse
|
||||
decodeResponseData(t, activeConfigResp, &activeConfig)
|
||||
if activeConfig.Version == "" || activeConfig.RenderedConfig == "" || activeConfig.Checksum == "" {
|
||||
t.Fatal("expected active config response to contain version payload")
|
||||
}
|
||||
|
||||
successApplyResp := performAgentJSONRequestWithToken(t, engine, createdNode.AgentToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
|
||||
"node_id": "spoofed-node-id",
|
||||
"version": activeConfig.Version,
|
||||
"result": service.ApplyResultOK,
|
||||
"message": "apply ok",
|
||||
})
|
||||
var successApplyLog model.ApplyLog
|
||||
decodeResponseData(t, successApplyResp, &successApplyLog)
|
||||
if successApplyLog.Result != service.ApplyResultOK {
|
||||
t.Fatal("expected apply log success to be recorded")
|
||||
}
|
||||
|
||||
failedApplyResp := performAgentJSONRequestWithToken(t, engine, createdNode.AgentToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
|
||||
"node_id": "spoofed-node-id",
|
||||
"version": activeConfig.Version,
|
||||
"result": service.ApplyResultFailed,
|
||||
"message": "openresty reload failed",
|
||||
})
|
||||
var failedApplyLog model.ApplyLog
|
||||
decodeResponseData(t, failedApplyResp, &failedApplyLog)
|
||||
if failedApplyLog.Result != service.ApplyResultFailed {
|
||||
t.Fatal("expected failed apply log to be recorded")
|
||||
}
|
||||
|
||||
nodesResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/", nil)
|
||||
var nodes []service.NodeView
|
||||
decodeResponseData(t, nodesResp, &nodes)
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||
}
|
||||
if nodes[0].Status != service.NodeStatusOnline {
|
||||
t.Fatal("expected registered node to become online")
|
||||
}
|
||||
if nodes[0].AgentToken != createdNode.AgentToken {
|
||||
t.Fatal("expected node auth token to remain stable after occupancy")
|
||||
}
|
||||
if nodes[0].LatestApplyResult != service.ApplyResultFailed || nodes[0].LatestApplyMessage != "openresty reload failed" {
|
||||
t.Fatal("expected node list to expose latest apply status")
|
||||
}
|
||||
if nodes[0].CurrentVersion != activeConfig.Version {
|
||||
t.Fatal("expected node current_version to remain at last successful version")
|
||||
}
|
||||
if nodes[0].LastError != "openresty reload failed" {
|
||||
t.Fatal("expected node last_error to reflect failed apply")
|
||||
}
|
||||
if nodes[0].OpenrestyStatus != service.OpenrestyStatusUnhealthy {
|
||||
t.Fatal("expected node list to expose openresty status")
|
||||
}
|
||||
if nodes[0].OpenrestyMessage != "docker run openresty failed: bind 80 already allocated" {
|
||||
t.Fatal("expected node list to expose openresty message")
|
||||
}
|
||||
|
||||
observabilityResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/"+toString(createdNode.ID)+"/observability?hours=24&limit=20", nil)
|
||||
var observability service.NodeObservabilityView
|
||||
decodeResponseData(t, observabilityResp, &observability)
|
||||
if observability.NodeID != createdNode.NodeID {
|
||||
t.Fatalf("expected observability response for node %s, got %s", createdNode.NodeID, observability.NodeID)
|
||||
}
|
||||
|
||||
restartResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/"+toString(createdNode.ID)+"/openresty-restart", nil)
|
||||
decodeResponseData(t, restartResp, &createdNode)
|
||||
if !createdNode.RestartOpenrestyRequested {
|
||||
t.Fatal("expected openresty restart request flag to be set")
|
||||
}
|
||||
|
||||
rawHeartbeatPayload, err := json.Marshal(heartbeatPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal heartbeat payload: %v", err)
|
||||
}
|
||||
restartHeartbeatReq := httptest.NewRequest(http.MethodPost, "/api/agent/nodes/heartbeat", bytes.NewReader(rawHeartbeatPayload))
|
||||
restartHeartbeatReq.Header.Set("Content-Type", "application/json")
|
||||
restartHeartbeatReq.Header.Set("X-Agent-Token", createdNode.AgentToken)
|
||||
restartHeartbeatRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(restartHeartbeatRecorder, restartHeartbeatReq)
|
||||
if restartHeartbeatRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected heartbeat status %d: %s", restartHeartbeatRecorder.Code, restartHeartbeatRecorder.Body.String())
|
||||
}
|
||||
var restartHeartbeatBody struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
AgentSettings service.AgentSettings `json:"agent_settings"`
|
||||
ActiveConfig *service.ActiveConfigMeta `json:"active_config"`
|
||||
}
|
||||
if err = json.Unmarshal(restartHeartbeatRecorder.Body.Bytes(), &restartHeartbeatBody); err != nil {
|
||||
t.Fatalf("failed to decode heartbeat response: %v", err)
|
||||
}
|
||||
if !restartHeartbeatBody.Success {
|
||||
t.Fatalf("expected heartbeat request success, got %s", restartHeartbeatBody.Message)
|
||||
}
|
||||
if !restartHeartbeatBody.AgentSettings.RestartOpenrestyNow {
|
||||
t.Fatal("expected heartbeat response to instruct openresty restart")
|
||||
}
|
||||
if restartHeartbeatBody.ActiveConfig == nil || restartHeartbeatBody.ActiveConfig.Version == "" || restartHeartbeatBody.ActiveConfig.Checksum == "" {
|
||||
t.Fatal("expected heartbeat response to include active config summary")
|
||||
}
|
||||
|
||||
logsResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/apply-logs/?node_id="+createdNode.NodeID, nil)
|
||||
var logs []model.ApplyLog
|
||||
decodeResponseData(t, logsResp, &logs)
|
||||
if len(logs) != 2 {
|
||||
t.Fatalf("expected 2 apply logs, got %d", len(logs))
|
||||
}
|
||||
|
||||
updatedNodeResp := performJSONRequest(t, engine, adminToken, http.MethodPut, "/api/nodes/"+toString(createdNode.ID), map[string]any{
|
||||
"name": "shanghai-edge-1-renamed",
|
||||
"geo_manual_override": true,
|
||||
"geo_name": "Tokyo",
|
||||
"geo_latitude": 35.6762,
|
||||
"geo_longitude": 139.6503,
|
||||
})
|
||||
decodeResponseData(t, updatedNodeResp, &createdNode)
|
||||
if createdNode.Name != "shanghai-edge-1-renamed" {
|
||||
t.Fatal("expected node name to be editable")
|
||||
}
|
||||
if createdNode.GeoName != "Tokyo" || createdNode.GeoLatitude == nil || createdNode.GeoLongitude == nil {
|
||||
t.Fatalf("expected node geo metadata to be editable, got %+v", createdNode)
|
||||
}
|
||||
|
||||
oldTime := time.Now().Add(-common.NodeOfflineThreshold - time.Minute)
|
||||
if err := model.DB.Model(&model.Node{}).Where("node_id = ?", createdNode.NodeID).Update("last_seen_at", oldTime).Error; err != nil {
|
||||
t.Fatalf("failed to update node last_seen_at: %v", err)
|
||||
}
|
||||
nodesResp = performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/", nil)
|
||||
decodeResponseData(t, nodesResp, &nodes)
|
||||
if nodes[0].Status != service.NodeStatusOffline {
|
||||
t.Fatal("expected node to be shown as offline after timeout")
|
||||
}
|
||||
|
||||
deleteResp := performJSONRequest(t, engine, adminToken, http.MethodDelete, "/api/nodes/"+toString(createdNode.ID), nil)
|
||||
if !deleteResp.Success {
|
||||
t.Fatalf("expected delete node success, got %s", deleteResp.Message)
|
||||
}
|
||||
|
||||
deniedReq := httptest.NewRequest(http.MethodPost, "/api/agent/nodes/heartbeat", bytes.NewReader([]byte(`{"ip":"10.0.0.9","agent_version":"0.1.1"}`)))
|
||||
deniedReq.Header.Set("Content-Type", "application/json")
|
||||
deniedReq.Header.Set("X-Agent-Token", createdNode.AgentToken)
|
||||
deniedRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(deniedRecorder, deniedReq)
|
||||
if deniedRecorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected deleted node token to be rejected, got %d", deniedRecorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhase2CustomHeadersPreviewAndDiffLifecycle(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.RedisEnabled = false
|
||||
setupTestDB(t)
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
|
||||
router.SetApiRouter(engine)
|
||||
|
||||
token := prepareRootToken(t)
|
||||
|
||||
createResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", map[string]any{
|
||||
"domain": "preview.example.com",
|
||||
"origin_url": "https://origin-a.internal",
|
||||
"enabled": true,
|
||||
"custom_headers": []map[string]any{
|
||||
{"key": "X-Trace-Id", "value": "$request_id"},
|
||||
},
|
||||
})
|
||||
var createdRoute model.ProxyRoute
|
||||
decodeResponseData(t, createResp, &createdRoute)
|
||||
if !strings.Contains(createdRoute.CustomHeaders, "X-Trace-Id") {
|
||||
t.Fatalf("expected custom headers to be stored as json, got %s", createdRoute.CustomHeaders)
|
||||
}
|
||||
|
||||
performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil)
|
||||
|
||||
performJSONRequest(t, engine, token, http.MethodPut, "/api/proxy-routes/"+toString(createdRoute.ID), map[string]any{
|
||||
"domain": "preview.example.com",
|
||||
"origin_url": "https://origin-b.internal",
|
||||
"enabled": true,
|
||||
"custom_headers": []map[string]any{
|
||||
{"key": "X-Trace-Id", "value": "$request_id"},
|
||||
{"key": "X-Release", "value": "candidate"},
|
||||
},
|
||||
})
|
||||
performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", map[string]any{
|
||||
"domain": "new-preview.example.com",
|
||||
"origin_url": "https://origin-new.internal",
|
||||
"enabled": true,
|
||||
})
|
||||
|
||||
previewResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/preview", nil)
|
||||
var preview map[string]any
|
||||
decodeResponseData(t, previewResp, &preview)
|
||||
renderedConfig, _ := preview["rendered_config"].(string)
|
||||
if !strings.Contains(renderedConfig, `proxy_set_header X-Release "candidate";`) {
|
||||
t.Fatalf("expected preview endpoint to return custom header, got %s", renderedConfig)
|
||||
}
|
||||
|
||||
diffResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/diff", nil)
|
||||
var diff map[string]any
|
||||
decodeResponseData(t, diffResp, &diff)
|
||||
modifiedDomains, ok := diff["modified_domains"].([]any)
|
||||
if !ok || len(modifiedDomains) != 1 || modifiedDomains[0].(string) != "preview.example.com" {
|
||||
t.Fatalf("unexpected modified domains: %#v", diff["modified_domains"])
|
||||
}
|
||||
addedDomains, ok := diff["added_domains"].([]any)
|
||||
if !ok || len(addedDomains) != 1 || addedDomains[0].(string) != "new-preview.example.com" {
|
||||
t.Fatalf("unexpected added domains: %#v", diff["added_domains"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhase2GlobalDiscoveryRegistration(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.RedisEnabled = false
|
||||
setupTestDB(t)
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
|
||||
router.SetApiRouter(engine)
|
||||
|
||||
adminToken := prepareRootToken(t)
|
||||
bootstrapResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/bootstrap-token", nil)
|
||||
var bootstrap service.NodeBootstrapView
|
||||
decodeResponseData(t, bootstrapResp, &bootstrap)
|
||||
if bootstrap.DiscoveryToken == "" {
|
||||
t.Fatal("expected global discovery token to be available")
|
||||
}
|
||||
|
||||
resp := performAgentJSONRequestWithToken(t, engine, bootstrap.DiscoveryToken, http.MethodPost, "/api/agent/nodes/register", map[string]any{
|
||||
"node_id": "local-node-id",
|
||||
"name": "bulk-edge-1",
|
||||
"ip": "10.0.0.18",
|
||||
"agent_version": "0.2.0",
|
||||
"nginx_version": "1.25.5",
|
||||
"current_version": "",
|
||||
"last_error": "",
|
||||
})
|
||||
var registration service.AgentRegistrationResponse
|
||||
decodeResponseData(t, resp, ®istration)
|
||||
if registration.AgentToken == "" || registration.NodeID == "" {
|
||||
t.Fatal("expected discovery registration to issue node-specific agent token")
|
||||
}
|
||||
|
||||
nodesResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/", nil)
|
||||
var nodes []service.NodeView
|
||||
decodeResponseData(t, nodesResp, &nodes)
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 discovered node, got %d", len(nodes))
|
||||
}
|
||||
if nodes[0].Name != "bulk-edge-1" || nodes[0].AgentToken != registration.AgentToken || nodes[0].Status != service.NodeStatusOnline {
|
||||
t.Fatal("expected discovered node to be created online with issued agent token")
|
||||
}
|
||||
}
|
||||
|
||||
func performAgentJSONRequestWithToken(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse {
|
||||
t.Helper()
|
||||
var payload []byte
|
||||
var err error
|
||||
if body != nil {
|
||||
payload, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal request body: %v", err)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(payload))
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("X-Agent-Token", token)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status %d for %s %s: %s", recorder.Code, method, path, recorder.Body.String())
|
||||
}
|
||||
var resp apiResponse
|
||||
if err = json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("request %s %s failed: %s", method, path, resp.Message)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func createRouteAndPublishVersion(t *testing.T, engine http.Handler, adminToken string) {
|
||||
t.Helper()
|
||||
createBody := map[string]any{
|
||||
"domain": "agent.example.com",
|
||||
"origin_url": "https://agent-origin.internal",
|
||||
"enabled": true,
|
||||
"remark": "agent route",
|
||||
}
|
||||
performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/proxy-routes/", createBody)
|
||||
performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/config-versions/publish", nil)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"openflare/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
)
|
||||
|
||||
func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
|
||||
SetApiRouter(router)
|
||||
swaggerRoute := router.Group("/swagger")
|
||||
swaggerRoute.Use(middleware.AdminAuth())
|
||||
swaggerRoute.GET("/*any", ginSwagger.WrapHandler(
|
||||
swaggerFiles.Handler,
|
||||
ginSwagger.URL("/swagger/doc.json"),
|
||||
ginSwagger.DocExpansion("list"),
|
||||
ginSwagger.PersistAuthorization(true),
|
||||
ginSwagger.DefaultModelsExpandDepth(1),
|
||||
))
|
||||
setWebRouter(router, buildFS, indexPage)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package router_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGeneratedSwaggerSpecExists(t *testing.T) {
|
||||
data, err := os.ReadFile("../docs/swagger.json")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read generated swagger spec: %v", err)
|
||||
}
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "\"title\": \"OpenFlare Server API\"") {
|
||||
t.Fatal("expected swagger spec title to exist")
|
||||
}
|
||||
if !strings.Contains(content, "\"/api/proxy-routes/\"") {
|
||||
t.Fatal("expected swagger spec to contain proxy route endpoint")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package router_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"openflare/common"
|
||||
"openflare/router"
|
||||
"openflare/service"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type roundTripFunc func(req *http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestLatestReleaseProxy(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.RedisEnabled = false
|
||||
setupTestDB(t)
|
||||
|
||||
originalClient := service.UpdateHTTPClientForTest()
|
||||
service.SetUpdateHTTPClientForTest(&http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.String() != "https://api.github.com/repos/Rain-kl/OpenFlare/releases/latest" {
|
||||
t.Fatalf("unexpected request url: %s", req.URL.String())
|
||||
}
|
||||
if req.Header.Get("Accept") != "application/vnd.github+json" {
|
||||
t.Fatalf("unexpected accept header: %s", req.Header.Get("Accept"))
|
||||
}
|
||||
if req.Header.Get("User-Agent") != "OpenFlare-Server" {
|
||||
t.Fatalf("unexpected user-agent header: %s", req.Header.Get("User-Agent"))
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"tag_name":"v1.2.3",
|
||||
"body":"release notes",
|
||||
"html_url":"https://github.com/Rain-kl/OpenFlare/releases/tag/v1.2.3",
|
||||
"published_at":"2026-03-11T00:00:00Z"
|
||||
}`)),
|
||||
}, nil
|
||||
}),
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
service.SetUpdateHTTPClientForTest(originalClient)
|
||||
})
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
|
||||
router.SetApiRouter(engine)
|
||||
|
||||
loginBody, err := json.Marshal(map[string]string{
|
||||
"username": "root",
|
||||
"password": "123456",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal login body: %v", err)
|
||||
}
|
||||
loginReq := httptest.NewRequest(http.MethodPost, "/api/user/login", bytes.NewReader(loginBody))
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(loginRecorder, loginReq)
|
||||
if loginRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected login status code: %d", loginRecorder.Code)
|
||||
}
|
||||
loginResult := loginRecorder.Result()
|
||||
defer loginResult.Body.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/update/latest-release", nil)
|
||||
for _, cookieValue := range loginResult.Cookies() {
|
||||
req.AddCookie(cookieValue)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status code: %d", recorder.Code)
|
||||
}
|
||||
|
||||
var resp apiResponse
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("expected success response, got message: %s", resp.Message)
|
||||
}
|
||||
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(resp.Data, &data); err != nil {
|
||||
t.Fatalf("failed to decode response data: %v", err)
|
||||
}
|
||||
if data["tag_name"] != "v1.2.3" {
|
||||
t.Fatalf("unexpected tag_name: %#v", data["tag_name"])
|
||||
}
|
||||
if data["current_version"] != common.Version {
|
||||
t.Fatalf("unexpected current_version: %#v", data["current_version"])
|
||||
}
|
||||
}
|
||||
|
||||
func loginRootAndBuildEngine(t *testing.T) (*gin.Engine, []*http.Cookie) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.RedisEnabled = false
|
||||
setupTestDB(t)
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
|
||||
router.SetApiRouter(engine)
|
||||
|
||||
loginBody, err := json.Marshal(map[string]string{
|
||||
"username": "root",
|
||||
"password": "123456",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal login body: %v", err)
|
||||
}
|
||||
loginReq := httptest.NewRequest(http.MethodPost, "/api/user/login", bytes.NewReader(loginBody))
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(loginRecorder, loginReq)
|
||||
if loginRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected login status code: %d", loginRecorder.Code)
|
||||
}
|
||||
loginResult := loginRecorder.Result()
|
||||
defer loginResult.Body.Close()
|
||||
|
||||
return engine, loginResult.Cookies()
|
||||
}
|
||||
|
||||
func fakeManualServerBinary(version string) (string, []byte) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "openflare-server-test.cmd", []byte("@echo off\r\necho " + version + "\r\n")
|
||||
}
|
||||
return "openflare-server-test.sh", []byte("#!/bin/sh\necho " + version + "\n")
|
||||
}
|
||||
|
||||
func TestManualUploadRoute(t *testing.T) {
|
||||
originalVersion := common.Version
|
||||
common.Version = "v0.4.0"
|
||||
t.Cleanup(func() {
|
||||
common.Version = originalVersion
|
||||
service.SetServerBinaryUpgradeExecutorForTest(nil)
|
||||
service.SetServerUpgradeDispatchDelayForTest(500 * time.Millisecond)
|
||||
})
|
||||
|
||||
engine, cookies := loginRootAndBuildEngine(t)
|
||||
fileName, content := fakeManualServerBinary("v0.5.0")
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("binary", fileName)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create form file: %v", err)
|
||||
}
|
||||
if _, err = part.Write(content); err != nil {
|
||||
t.Fatalf("failed to write upload content: %v", err)
|
||||
}
|
||||
if err = writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/update/manual-upload", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
for _, cookieValue := range cookies {
|
||||
req.AddCookie(cookieValue)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status code: %d", recorder.Code)
|
||||
}
|
||||
|
||||
var resp apiResponse
|
||||
if err = json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("expected success response, got message: %s", resp.Message)
|
||||
}
|
||||
|
||||
var data map[string]any
|
||||
if err = json.Unmarshal(resp.Data, &data); err != nil {
|
||||
t.Fatalf("failed to decode response data: %v", err)
|
||||
}
|
||||
if data["detected_version"] != "v0.5.0" {
|
||||
t.Fatalf("unexpected detected_version: %#v", data["detected_version"])
|
||||
}
|
||||
if data["ready_to_upgrade"] != true {
|
||||
t.Fatalf("expected ready_to_upgrade to be true: %#v", data["ready_to_upgrade"])
|
||||
}
|
||||
if data["upload_token"] == "" {
|
||||
t.Fatal("expected upload_token to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualUpgradeConfirmRoute(t *testing.T) {
|
||||
originalVersion := common.Version
|
||||
originalExecutor := service.ServerBinaryUpgradeExecutorForTest()
|
||||
originalDelay := service.ServerUpgradeDispatchDelayForTest()
|
||||
common.Version = "v0.4.0"
|
||||
called := make(chan string, 1)
|
||||
service.SetServerBinaryUpgradeExecutorForTest(func(execPath string, tempPath string) error {
|
||||
called <- tempPath
|
||||
return nil
|
||||
})
|
||||
service.SetServerUpgradeDispatchDelayForTest(0)
|
||||
t.Cleanup(func() {
|
||||
common.Version = originalVersion
|
||||
service.SetServerBinaryUpgradeExecutorForTest(originalExecutor)
|
||||
service.SetServerUpgradeDispatchDelayForTest(originalDelay)
|
||||
})
|
||||
|
||||
engine, cookies := loginRootAndBuildEngine(t)
|
||||
fileName, content := fakeManualServerBinary("v0.5.0")
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("binary", fileName)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create form file: %v", err)
|
||||
}
|
||||
if _, err = part.Write(content); err != nil {
|
||||
t.Fatalf("failed to write upload content: %v", err)
|
||||
}
|
||||
if err = writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
uploadReq := httptest.NewRequest(http.MethodPost, "/api/update/manual-upload", body)
|
||||
uploadReq.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
for _, cookieValue := range cookies {
|
||||
uploadReq.AddCookie(cookieValue)
|
||||
}
|
||||
|
||||
uploadRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(uploadRecorder, uploadReq)
|
||||
if uploadRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected upload status code: %d", uploadRecorder.Code)
|
||||
}
|
||||
|
||||
var uploadResp apiResponse
|
||||
if err = json.Unmarshal(uploadRecorder.Body.Bytes(), &uploadResp); err != nil {
|
||||
t.Fatalf("failed to decode upload response: %v", err)
|
||||
}
|
||||
if !uploadResp.Success {
|
||||
t.Fatalf("expected upload success, got message: %s", uploadResp.Message)
|
||||
}
|
||||
|
||||
var uploadData map[string]any
|
||||
if err = json.Unmarshal(uploadResp.Data, &uploadData); err != nil {
|
||||
t.Fatalf("failed to decode upload response data: %v", err)
|
||||
}
|
||||
uploadToken, _ := uploadData["upload_token"].(string)
|
||||
if uploadToken == "" {
|
||||
t.Fatal("expected upload token in upload response")
|
||||
}
|
||||
|
||||
confirmBody, err := json.Marshal(map[string]string{"upload_token": uploadToken})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal confirm body: %v", err)
|
||||
}
|
||||
confirmReq := httptest.NewRequest(http.MethodPost, "/api/update/manual-upgrade", bytes.NewReader(confirmBody))
|
||||
confirmReq.Header.Set("Content-Type", "application/json")
|
||||
for _, cookieValue := range cookies {
|
||||
confirmReq.AddCookie(cookieValue)
|
||||
}
|
||||
|
||||
confirmRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(confirmRecorder, confirmReq)
|
||||
if confirmRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected confirm status code: %d", confirmRecorder.Code)
|
||||
}
|
||||
|
||||
var confirmResp apiResponse
|
||||
if err = json.Unmarshal(confirmRecorder.Body.Bytes(), &confirmResp); err != nil {
|
||||
t.Fatalf("failed to decode confirm response: %v", err)
|
||||
}
|
||||
if !confirmResp.Success {
|
||||
t.Fatalf("expected confirm success, got message: %s", confirmResp.Message)
|
||||
}
|
||||
|
||||
select {
|
||||
case tempPath := <-called:
|
||||
if tempPath == "" {
|
||||
t.Fatal("expected manual upgrade executor to receive temp path")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected manual upgrade executor to be called")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"github.com/gin-contrib/static"
|
||||
"github.com/gin-gonic/gin"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"openflare/controller"
|
||||
"openflare/middleware"
|
||||
"openflare/utils/embedfs"
|
||||
pathpkg "path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func setWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
|
||||
exportedBuildFS, err := fs.Sub(buildFS, "web/build")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
router.Use(middleware.GlobalWebRateLimit())
|
||||
fileDownloadRoute := router.Group("/")
|
||||
fileDownloadRoute.GET("/upload/:file", middleware.DownloadRateLimit(), controller.DownloadFile)
|
||||
router.Use(normalizeStaticExportDataNavigation())
|
||||
router.Use(middleware.Cache())
|
||||
router.Use(static.Serve("/", embedfs.EmbedFolder(buildFS, "web/build")))
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
if serveExportedPage(c, exportedBuildFS) {
|
||||
return
|
||||
}
|
||||
|
||||
if isStaticAssetRequest(c.Request.URL.Path) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", indexPage)
|
||||
})
|
||||
}
|
||||
|
||||
func serveExportedPage(c *gin.Context, buildFS fs.FS) bool {
|
||||
requestPath := strings.Trim(c.Request.URL.Path, "/")
|
||||
|
||||
candidates := []string{"index.html"}
|
||||
if requestPath != "" {
|
||||
candidates = []string{
|
||||
requestPath + ".html",
|
||||
pathpkg.Join(requestPath, "index.html"),
|
||||
}
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
content, err := fs.ReadFile(buildFS, candidate)
|
||||
if err == nil {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", content)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeStaticExportDataNavigation() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
requestPath := c.Request.URL.Path
|
||||
if strings.HasSuffix(requestPath, ".txt") && isDocumentNavigationRequest(c.Request) {
|
||||
normalizedPath := strings.TrimSuffix(requestPath, ".txt")
|
||||
if normalizedPath == "" {
|
||||
normalizedPath = "/"
|
||||
}
|
||||
c.Request.URL.Path = normalizedPath
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func isDocumentNavigationRequest(request *http.Request) bool {
|
||||
if request.Header.Get("Sec-Fetch-Mode") == "navigate" || request.Header.Get("Sec-Fetch-Dest") == "document" {
|
||||
return true
|
||||
}
|
||||
|
||||
return strings.Contains(request.Header.Get("Accept"), "text/html")
|
||||
}
|
||||
|
||||
func isStaticAssetRequest(requestPath string) bool {
|
||||
return strings.HasPrefix(requestPath, "/_next/") || pathpkg.Ext(requestPath) != ""
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"openflare/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestNormalizeStaticExportDataNavigationRewritesDocumentRequests(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(normalizeStaticExportDataNavigation())
|
||||
engine.GET("/*any", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, c.Request.URL.Path)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/website.txt", nil)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||
req.Header.Set("Sec-Fetch-Mode", "navigate")
|
||||
req.Header.Set("Sec-Fetch-Dest", "document")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if body := recorder.Body.String(); body != "/website" {
|
||||
t.Fatalf("expected document request to be rewritten to /website, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStaticExportDataNavigationKeepsDataRequests(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(normalizeStaticExportDataNavigation())
|
||||
engine.GET("/*any", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, c.Request.URL.Path)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/website.txt", nil)
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Sec-Fetch-Mode", "cors")
|
||||
req.Header.Set("Sec-Fetch-Dest", "empty")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if body := recorder.Body.String(); body != "/website.txt" {
|
||||
t.Fatalf("expected data request to keep txt path, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheHeadersDisableExportedPageCaching(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(middleware.Cache())
|
||||
engine.GET("/website", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/website", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
|
||||
if got := recorder.Header().Get("Cache-Control"); got != "no-store, no-cache, must-revalidate" {
|
||||
t.Fatalf("unexpected cache-control for page: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheHeadersKeepImmutableStaticAssets(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(middleware.Cache())
|
||||
engine.GET("/_next/static/app.js", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/_next/static/app.js", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
|
||||
if got := recorder.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" {
|
||||
t.Fatalf("unexpected cache-control for static asset: %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user