Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions core/src/router/middleware_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,47 @@
package router

import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/clidey/whodb/core/src/analytics"
)

func setValidRuntimeHealthConfig(t *testing.T) {
t.Helper()
t.Setenv("PORT", "")
t.Setenv("WHODB_METADATA_DSN", "host=metadata.local user=dataflow password=metadata-secret dbname=dataflow")
t.Setenv("WHODB_SESSION_DSN", "")
t.Setenv("WHODB_SESSION_ENCRYPTION_KEY", "12345678901234567890123456789012")
t.Setenv("WHODB_SESSION_TTL", "24h")
t.Setenv("WHODB_SEALOS_BOOTSTRAP_ENABLED", "")
t.Setenv("WHODB_STANDALONE_LOGIN_ENABLED", "")
t.Setenv("WHODB_ENABLE_AWS_PROVIDER", "")
t.Setenv("WHODB_AWS_PROVIDER", "")
}

func decodeHealthResponse(t *testing.T, body string) healthResponse {
t.Helper()
var response healthResponse
if err := json.Unmarshal([]byte(body), &response); err != nil {
t.Fatalf("failed to decode health response: %v; body=%s", err, body)
}
return response
}

func assertHealthIssue(t *testing.T, response healthResponse, name string) {
t.Helper()
for _, issue := range response.Checks {
if issue.Name == name {
return
}
}
t.Fatalf("expected health issue %s, got %+v", name, response.Checks)
}

func TestContextMiddlewareAddsMetadata(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://api.local/data", nil)
req.Host = "api.local:8080"
Expand Down Expand Up @@ -39,3 +73,160 @@ func TestContextMiddlewareAddsMetadata(t *testing.T) {
t.Fatalf("expected request id to be captured from header, got %s", captured.RequestID)
}
}

func TestHealthCheckMiddlewareHandlesHealthz(t *testing.T) {
setValidRuntimeHealthConfig(t)

handler := healthCheckMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTeapot)
}))

rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "http://api.local/healthz", nil)

handler.ServeHTTP(rr, req)

if rr.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code)
}
if got := rr.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("expected Cache-Control no-store, got %q", got)
}
if got := rr.Header().Get("Content-Type"); got != "application/json" {
t.Fatalf("expected Content-Type application/json, got %q", got)
}
response := decodeHealthResponse(t, rr.Body.String())
if response.Service != "dataflow" || response.Status != "ok" {
t.Fatalf("unexpected healthz response: %+v", response)
}
if len(response.Checks) != 0 {
t.Fatalf("expected no checks on healthy response, got %+v", response.Checks)
}
}

func TestHealthCheckMiddlewareReportsInvalidRuntimeConfig(t *testing.T) {
tests := []struct {
name string
mutate func(t *testing.T)
wantIssue string
forbidden string
}{
{
name: "missing metadata dsn",
mutate: func(t *testing.T) {
t.Setenv("WHODB_METADATA_DSN", "")
},
wantIssue: "WHODB_METADATA_DSN",
forbidden: "metadata-secret",
},
{
name: "missing session source",
mutate: func(t *testing.T) {
t.Setenv("WHODB_METADATA_DSN", "")
t.Setenv("WHODB_SESSION_DSN", "")
},
wantIssue: "WHODB_SESSION_DSN",
forbidden: "metadata-secret",
},
{
name: "invalid session encryption key",
mutate: func(t *testing.T) {
t.Setenv("WHODB_SESSION_ENCRYPTION_KEY", "short-secret")
},
wantIssue: "WHODB_SESSION_ENCRYPTION_KEY",
forbidden: "short-secret",
},
{
name: "invalid session ttl",
mutate: func(t *testing.T) {
t.Setenv("WHODB_SESSION_TTL", "soon")
},
wantIssue: "WHODB_SESSION_TTL",
forbidden: "soon",
},
{
name: "invalid port",
mutate: func(t *testing.T) {
t.Setenv("PORT", "99999")
},
wantIssue: "PORT",
forbidden: "99999",
},
{
name: "invalid boolean",
mutate: func(t *testing.T) {
t.Setenv("WHODB_SEALOS_BOOTSTRAP_ENABLED", "yes")
},
wantIssue: "WHODB_SEALOS_BOOTSTRAP_ENABLED",
forbidden: "yes",
},
{
name: "invalid aws provider json",
mutate: func(t *testing.T) {
t.Setenv("WHODB_ENABLE_AWS_PROVIDER", "true")
t.Setenv("WHODB_AWS_PROVIDER", `{"region":"us-west-2"}`)
},
wantIssue: "WHODB_AWS_PROVIDER",
forbidden: "us-west-2",
},
{
name: "aws provider missing region",
mutate: func(t *testing.T) {
t.Setenv("WHODB_ENABLE_AWS_PROVIDER", "true")
t.Setenv("WHODB_AWS_PROVIDER", `[{"name":"prod"}]`)
},
wantIssue: "WHODB_AWS_PROVIDER",
forbidden: "prod",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setValidRuntimeHealthConfig(t)
tt.mutate(t)

handler := healthCheckMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTeapot)
}))

rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "http://api.local/healthz", nil)

handler.ServeHTTP(rr, req)

if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("expected status %d, got %d with body %s", http.StatusServiceUnavailable, rr.Code, rr.Body.String())
}
response := decodeHealthResponse(t, rr.Body.String())
if response.Service != "dataflow" || response.Status != "error" {
t.Fatalf("unexpected healthz response: %+v", response)
}
assertHealthIssue(t, response, tt.wantIssue)
if strings.Contains(rr.Body.String(), tt.forbidden) {
t.Fatalf("health response leaked configured value %q: %s", tt.forbidden, rr.Body.String())
}
})
}
}

func TestHealthCheckMiddlewareDoesNotHandleHealth(t *testing.T) {
handler := healthCheckMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTeapot)
w.Write([]byte("next"))
}))

rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "http://api.local/health", nil)

handler.ServeHTTP(rr, req)

if rr.Code != http.StatusTeapot {
t.Fatalf("expected request to continue to next handler, got status %d", rr.Code)
}
if got := rr.Header().Get("Cache-Control"); got != "" {
t.Fatalf("expected Cache-Control to be unset, got %q", got)
}
if got := strings.TrimSpace(rr.Body.String()); got != "next" {
t.Fatalf("expected next handler response, got %s", got)
}
}
118 changes: 113 additions & 5 deletions core/src/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ package router

import (
"embed"
"encoding/json"
"net/http"
"os"
"strconv"
"strings"
"time"

"github.com/99designs/gqlgen/graphql/handler/extension"
Expand All @@ -39,6 +43,17 @@ type OAuthLoginUrl struct {
Url string `json:"url"`
}

type healthResponse struct {
Service string `json:"service"`
Status string `json:"status"`
Checks []healthCheckIssue `json:"checks,omitempty"`
}

type healthCheckIssue struct {
Name string `json:"name"`
Reason string `json:"reason"`
}

func NewGraphQLServer(es graphql.ExecutableSchema) *handler.Server {
srv := handler.New(es)

Expand Down Expand Up @@ -88,19 +103,112 @@ func (w *statusResponseWriter) Flush() {
}
}

// healthCheckMiddleware responds to GET /health without requiring authentication.
// Used by E2E setup scripts to verify the server is ready to handle requests.
// healthCheckMiddleware responds to GET /healthz without requiring authentication.
// Used by probes and E2E setup scripts to verify the server has valid runtime configuration.
func healthCheckMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/health" {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
if r.Method == http.MethodGet && r.URL.Path == "/healthz" {
statusCode, response := buildHealthResponse()
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(response)
return
}
next.ServeHTTP(w, r)
})
}

func buildHealthResponse() (int, healthResponse) {
issues := validateRuntimeConfiguration()
if len(issues) > 0 {
return http.StatusServiceUnavailable, healthResponse{
Service: "dataflow",
Status: "error",
Checks: issues,
}
}

return http.StatusOK, healthResponse{
Service: "dataflow",
Status: "ok",
}
}

func validateRuntimeConfiguration() []healthCheckIssue {
var issues []healthCheckIssue

addIssue := func(name, reason string) {
issues = append(issues, healthCheckIssue{
Name: name,
Reason: reason,
})
}

if port := strings.TrimSpace(os.Getenv("PORT")); port != "" {
n, err := strconv.Atoi(port)
if err != nil || n < 1 || n > 65535 {
addIssue("PORT", "must_be_valid_port")
}
}

if strings.TrimSpace(os.Getenv("WHODB_METADATA_DSN")) == "" {
addIssue("WHODB_METADATA_DSN", "required")
}

if env.GetSessionDSN() == "" {
addIssue("WHODB_SESSION_DSN", "required_or_metadata_dsn_fallback")
}

if len(env.GetSessionEncryptionKey()) != 32 {
addIssue("WHODB_SESSION_ENCRYPTION_KEY", "must_be_32_bytes")
}

ttl, err := time.ParseDuration(env.GetSessionTTL())
if err != nil || ttl <= 0 {
addIssue("WHODB_SESSION_TTL", "must_be_positive_duration")
}

validateOptionalBoolean("WHODB_SEALOS_BOOTSTRAP_ENABLED", addIssue)
validateOptionalBoolean("WHODB_STANDALONE_LOGIN_ENABLED", addIssue)
validateOptionalBoolean("WHODB_ENABLE_AWS_PROVIDER", addIssue)
validateAWSProviderConfig(addIssue)

return issues
}

func validateOptionalBoolean(name string, addIssue func(string, string)) {
value := strings.TrimSpace(os.Getenv(name))
if value == "" || value == "true" || value == "false" {
return
}
addIssue(name, "must_be_true_or_false")
}

func validateAWSProviderConfig(addIssue func(string, string)) {
if strings.TrimSpace(os.Getenv("WHODB_ENABLE_AWS_PROVIDER")) != "true" {
return
}

value := strings.TrimSpace(os.Getenv("WHODB_AWS_PROVIDER"))
if value == "" {
return
}

var providers []env.AWSProviderEnvConfig
if err := json.Unmarshal([]byte(value), &providers); err != nil {
addIssue("WHODB_AWS_PROVIDER", "must_be_json_array")
return
}

for _, provider := range providers {
if strings.TrimSpace(provider.Region) == "" {
addIssue("WHODB_AWS_PROVIDER", "region_required")
return
}
}
}

// accessLogMiddleware logs HTTP requests with method, path, status, duration, host, and remote address.
func accessLogMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
9 changes: 9 additions & 0 deletions deploy/charts/dataflow/dataflow-values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,24 @@ app:
resources: {}

startupProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 5
failureThreshold: 24
timeoutSeconds: 3

livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 10
periodSeconds: 10

readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 10
periodSeconds: 10

Expand Down
2 changes: 1 addition & 1 deletion deploy/charts/dataflow/templates/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ metadata:
name: {{ .Values.app.name }}
annotations:
app.sealos.io/representative-meta.forced-icon-style: fill
dataflow.sealos.io/health-url: {{ printf "http://%s.%s.svc.cluster.local:%v/health" (include "dataflow.fullname" .) .Release.Namespace .Values.service.port | quote }}
dataflow.sealos.io/health-url: {{ printf "http://%s.%s.svc.cluster.local:%v/healthz" (include "dataflow.fullname" .) .Release.Namespace .Values.service.port | quote }}
namespace: {{ .Values.app.namespace }}
labels:
{{- include "dataflow.labels" . | nindent 4 }}
Expand Down
Loading
Loading