diff --git a/Makefile b/Makefile index 9077b3ef..47131e32 100644 --- a/Makefile +++ b/Makefile @@ -8,4 +8,7 @@ build-frontend: npm --prefix web/app run build run-frontend: - npm --prefix web/app run serve \ No newline at end of file + npm --prefix web/app run serve + +test: + go test -mod=vendor ./... -cover \ No newline at end of file diff --git a/config/config.go b/config/config.go index 731451d0..33cb256d 100644 --- a/config/config.go +++ b/config/config.go @@ -72,7 +72,7 @@ type Config struct { Kubernetes *k8s.Config `yaml:"kubernetes"` // Web is the configuration for the web listener - Web *webConfig `yaml:"web"` + Web *WebConfig `yaml:"web"` } // Get returns the configuration, or panics if the configuration hasn't loaded yet @@ -150,7 +150,7 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) { func validateWebConfig(config *Config) { if config.Web == nil { - config.Web = &webConfig{Address: DefaultAddress, Port: DefaultPort} + config.Web = &WebConfig{Address: DefaultAddress, Port: DefaultPort} } else { config.Web.validateAndSetDefaults() } diff --git a/config/web.go b/config/web.go index e69e9fc4..3e7309e2 100644 --- a/config/web.go +++ b/config/web.go @@ -5,9 +5,9 @@ import ( "math" ) -// webConfig is the structure which supports the configuration of the endpoint +// WebConfig is the structure which supports the configuration of the endpoint // which provides access to the web frontend -type webConfig struct { +type WebConfig struct { // Address to listen on (defaults to 0.0.0.0 specified by DefaultAddress) Address string `yaml:"address"` @@ -16,7 +16,7 @@ type webConfig struct { } // validateAndSetDefaults checks and sets the default values for fields that are not set -func (web *webConfig) validateAndSetDefaults() { +func (web *WebConfig) validateAndSetDefaults() { // Validate the Address if len(web.Address) == 0 { web.Address = DefaultAddress @@ -30,6 +30,6 @@ func (web *webConfig) validateAndSetDefaults() { } // SocketAddress returns the combination of the Address and the Port -func (web *webConfig) SocketAddress() string { +func (web *WebConfig) SocketAddress() string { return fmt.Sprintf("%s:%d", web.Address, web.Port) } diff --git a/config/web_test.go b/config/web_test.go index 6375381a..2a4e43ea 100644 --- a/config/web_test.go +++ b/config/web_test.go @@ -5,7 +5,7 @@ import ( ) func TestWebConfig_SocketAddress(t *testing.T) { - web := &webConfig{ + web := &WebConfig{ Address: "0.0.0.0", Port: 8081, } diff --git a/controller/controller.go b/controller/controller.go index bc6fb8f0..815e2f6e 100644 --- a/controller/controller.go +++ b/controller/controller.go @@ -25,6 +25,14 @@ const ( var ( cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.LeastRecentlyUsed) + + // staticFolder is the path to the location of the static folder from the root path of the project + // The only reason this is exposed is to allow running tests from a different path than the root path of the project + staticFolder = "./web/static" + + // server is the http.Server created by Handle. + // The only reason it exists is for testing purposes. + server *http.Server ) func init() { @@ -40,7 +48,7 @@ func Handle() { if os.Getenv("ENVIRONMENT") == "dev" { router = developmentCorsHandler(router) } - server := &http.Server{ + server = &http.Server{ Addr: fmt.Sprintf("%s:%d", cfg.Web.Address, cfg.Web.Port), Handler: router, ReadTimeout: 15 * time.Second, @@ -48,22 +56,27 @@ func Handle() { IdleTimeout: 15 * time.Second, } log.Println("[controller][Handle] Listening on" + cfg.Web.SocketAddress()) + if os.Getenv("ROUTER_TEST") == "true" { + return + } log.Fatal(server.ListenAndServe()) } // CreateRouter creates the router for the http server func CreateRouter(cfg *config.Config) *mux.Router { router := mux.NewRouter() - router.HandleFunc("/favicon.ico", favIconHandler).Methods("GET") // favicon needs to be always served from the root - router.HandleFunc("/services/{service}", spaHandler).Methods("GET") - router.HandleFunc("/api/v1/statuses", secureIfNecessary(cfg, serviceStatusesHandler)).Methods("GET") - router.HandleFunc("/api/v1/statuses/{key}", secureIfNecessary(cfg, GzipHandlerFunc(serviceStatusHandler))).Methods("GET") - router.HandleFunc("/api/v1/badges/uptime/{duration}/{identifier}", badgeHandler).Methods("GET") - router.HandleFunc("/health", healthHandler).Methods("GET") if cfg.Metrics { router.Handle("/metrics", promhttp.Handler()).Methods("GET") } - router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir("./web/static")))) + router.HandleFunc("/favicon.ico", favIconHandler).Methods("GET") + router.HandleFunc("/health", healthHandler).Methods("GET") + router.HandleFunc("/api/v1/statuses", secureIfNecessary(cfg, serviceStatusesHandler)).Methods("GET") // No GzipHandler for this one, because we cache the content + router.HandleFunc("/api/v1/statuses/{key}", secureIfNecessary(cfg, GzipHandlerFunc(serviceStatusHandler))).Methods("GET") + router.HandleFunc("/api/v1/badges/uptime/{duration}/{identifier}", badgeHandler).Methods("GET") + // SPA + router.HandleFunc("/services/{service}", spaHandler).Methods("GET") + // Everything else falls back on static content + router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder)))) return router } @@ -152,10 +165,10 @@ func healthHandler(writer http.ResponseWriter, _ *http.Request) { // favIconHandler handles requests for /favicon.ico func favIconHandler(writer http.ResponseWriter, request *http.Request) { - http.ServeFile(writer, request, "./web/static/favicon.ico") + http.ServeFile(writer, request, staticFolder+"/favicon.ico") } // spaHandler handles requests for /favicon.ico func spaHandler(writer http.ResponseWriter, request *http.Request) { - http.ServeFile(writer, request, "./web/static/index.html") + http.ServeFile(writer, request, staticFolder+"/index.html") } diff --git a/controller/controller_test.go b/controller/controller_test.go new file mode 100644 index 00000000..8f7dfea7 --- /dev/null +++ b/controller/controller_test.go @@ -0,0 +1,169 @@ +package controller + +import ( + "math/rand" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/TwinProduction/gatus/config" + "github.com/TwinProduction/gatus/core" + "github.com/TwinProduction/gatus/watchdog" +) + +func TestCreateRouter(t *testing.T) { + staticFolder = "../web/static" + cfg := &config.Config{ + Metrics: true, + Services: []*core.Service{ + { + Name: "frontend", + Group: "core", + }, + { + Name: "backend", + Group: "core", + }, + }, + } + watchdog.UpdateServiceStatuses(cfg.Services[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) + watchdog.UpdateServiceStatuses(cfg.Services[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) + router := CreateRouter(cfg) + type Scenario struct { + Description string + Path string + ExpectedCode int + Gzip bool + } + scenarios := []Scenario{ + { + Description: "health", + Path: "/health", + ExpectedCode: http.StatusOK, + }, + { + Description: "metrics", + Path: "/metrics", + ExpectedCode: http.StatusOK, + }, + { + Description: "badges-1h", + Path: "/api/v1/badges/uptime/1h/core_frontend.svg", + ExpectedCode: http.StatusOK, + }, + { + Description: "badges-24h", + Path: "/api/v1/badges/uptime/24h/core_backend.svg", + ExpectedCode: http.StatusOK, + }, + { + Description: "badges-7d", + Path: "/api/v1/badges/uptime/7d/core_frontend.svg", + ExpectedCode: http.StatusOK, + }, + { + Description: "badges-with-invalid-duration", + Path: "/api/v1/badges/uptime/3d/core_backend.svg", + ExpectedCode: http.StatusBadRequest, + }, + { + Description: "badges-for-invalid-key", + Path: "/api/v1/badges/uptime/7d/invalid_key.svg", + ExpectedCode: http.StatusNotFound, + }, + { + Description: "service-statuses", + Path: "/api/v1/statuses", + ExpectedCode: http.StatusOK, + }, + { + Description: "service-statuses-gzip", + Path: "/api/v1/statuses", + ExpectedCode: http.StatusOK, + Gzip: true, + }, + { + Description: "service-status", + Path: "/api/v1/statuses/core_frontend", + ExpectedCode: http.StatusOK, + }, + { + Description: "service-status-gzip", + Path: "/api/v1/statuses/core_frontend", + ExpectedCode: http.StatusOK, + Gzip: true, + }, + { + Description: "service-status-for-invalid-key", + Path: "/api/v1/statuses/invalid_key", + ExpectedCode: http.StatusNotFound, + }, + { + Description: "favicon", + Path: "/favicon.ico", + ExpectedCode: http.StatusOK, + }, + { + Description: "frontend-home", + Path: "/", + ExpectedCode: http.StatusOK, + }, + { + Description: "frontend-assets", + Path: "/js/app.js", + ExpectedCode: http.StatusOK, + }, + { + Description: "frontend-service", + Path: "/services/core_frontend", + ExpectedCode: http.StatusOK, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Description, func(t *testing.T) { + request, _ := http.NewRequest("GET", scenario.Path, nil) + if scenario.Gzip { + request.Header.Set("Accept-Encoding", "gzip") + } + responseRecorder := httptest.NewRecorder() + router.ServeHTTP(responseRecorder, request) + if responseRecorder.Code != scenario.ExpectedCode { + t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code) + } + }) + } +} + +func TestHandle(t *testing.T) { + cfg := &config.Config{ + Web: &config.WebConfig{ + Address: "0.0.0.0", + Port: rand.Intn(65534), + }, + Services: []*core.Service{ + { + Name: "frontend", + Group: "core", + }, + { + Name: "backend", + Group: "core", + }, + }, + } + config.Set(cfg) + _ = os.Setenv("ROUTER_TEST", "true") + _ = os.Setenv("ENVIRONMENT", "dev") + Handle() + request, _ := http.NewRequest("GET", "/health", nil) + responseRecorder := httptest.NewRecorder() + server.Handler.ServeHTTP(responseRecorder, request) + if responseRecorder.Code != http.StatusOK { + t.Error("expected GET /health to return status code 200") + } + if server == nil { + t.Fatal("server should've been set (but because we set ROUTER_TEST, it shouldn't have been started)") + } +}