From a98db527c7c41b856c770657e1757f54c15de24c Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 8 Oct 2026 02:29:38 +0200 Subject: [PATCH] feat(echo): the status route Ports fastapi-guard guard/status.py add_status_route onto the echo adapter (the nethttp sibling's seam): NewStatusHandler serves the engine's initialization status JSON (cloud_providers table with ready / last_refreshed / entries rows, the geo handler's get_status snapshot or null, and the redis component with a live PTTL probe), and AddStatusRoute registers it on the echo instance at DefaultStatusPath or a custom path. GET only; a nil engine or app is rejected. --- status.go | 153 +++++++++++++++++++++++++++++++++ status_test.go | 229 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 status.go create mode 100644 status_test.go diff --git a/status.go b/status.go new file mode 100644 index 0000000..caa41fc --- /dev/null +++ b/status.go @@ -0,0 +1,153 @@ +package echo + +import ( + "errors" + "net/http" + "time" + + echolib "github.com/labstack/echo/v4" + "github.com/rennf93/guard-core-go/v4/guardcore" +) + +// Status route, ported from fastapi-guard guard/status.py (add_status_route) +// serving the payload of guard_core HandlerInitializer.get_initialization_status +// (guard_core/core/initialization/handler_initializer.py): the cloud-provider +// table and the geo-ip snapshot, plus the nethttp sibling's per-component +// redis health (the Go engine exposes its redis manager directly, so the +// status route doubles as a live dependency probe). + +// DefaultStatusPath is the route path add_status_route registers by default +// (the reference's /_guard/status). +const DefaultStatusPath = "/_guard/status" + +// redisStatusProbeKeySuffix is the key the redis health probe PTTLs: O(1) +// server side, matches nothing, and redis.Nil (key not found) is swallowed +// by the manager like any other miss, so only a connection failure +// surfaces. +const redisStatusProbeKeySuffix = "__status_probe__" + +// geoStatusProvider is the optional status surface of the configured +// CountryResolver: the built-in *guardcore.GeoIPManager implements it +// (GeoIPManager.GetStatus mirrors IPInfoManager.get_status), foreign +// resolvers may too. This mirrors the reference's getattr(geo_ip_handler, +// "get_status") fallback dance. +type geoStatusProvider interface { + GetStatus() map[string]any +} + +// ComponentStatus is one cloud provider's row of the reference +// cloud_handler.get_status payload. +type ComponentStatus struct { + Ready bool `json:"ready"` + LastRefreshed *time.Time `json:"last_refreshed"` + Entries int `json:"entries"` +} + +// RedisStatus is the redis component of the status payload: whether the +// engine was configured with redis, and - when it was - whether the manager +// answers a probe right now. ok defaults true when redis is disabled (there +// is nothing to be down), mirroring the PHP siblings' initialize() snapshot. +type RedisStatus struct { + Enabled bool `json:"enabled"` + OK bool `json:"ok"` + Error *string `json:"error"` +} + +// InitializationStatus is the JSON payload the status route serves. +type InitializationStatus struct { + CloudProviders map[string]ComponentStatus `json:"cloud_providers"` + GeoIP map[string]any `json:"geo_ip"` + Redis RedisStatus `json:"redis"` +} + +// initializationStatus snapshots the engine's initialization surface. It +// never mutates engine state: cloud and geo read the managers' current +// tables, and the redis probe answers from a live connection when the +// manager already has one (the app's startup Initialize()); a manager with +// no client yet dials lazily exactly like any other guard operation, so a +// status GET cannot fail a request the engine would otherwise have served. +func initializationStatus(engine *guardcore.Engine) InitializationStatus { + status := InitializationStatus{ + CloudProviders: map[string]ComponentStatus{}, + GeoIP: geoIPStatus(engine), + Redis: redisStatus(engine), + } + for provider, raw := range engine.Cloud.Status() { + // A provider that never refreshed carries the zero time; the + // reference's last_updated.get(provider) reports null there. + var refreshed *time.Time + if !raw.LastRefreshed.IsZero() { + refreshed = &raw.LastRefreshed + } + status.CloudProviders[provider] = ComponentStatus{ + Ready: raw.Ready, + LastRefreshed: refreshed, + Entries: raw.Entries, + } + } + return status +} + +// geoIPStatus serves the geo_ip component: the handler's own get_status +// snapshot when it provides one (the built-in manager does), nil otherwise +// - the reference reports null geo_ip when no handler is configured. +func geoIPStatus(engine *guardcore.Engine) map[string]any { + provider, ok := engine.Config.GeoIPHandler.(geoStatusProvider) + if !ok { + return nil + } + return provider.GetStatus() +} + +// redisStatus serves the redis component: enabled plus a live probe. A +// disabled manager reports enabled=false, ok=true, error=null; a probe +// failure reports ok=false with the redacted failure string. +func redisStatus(engine *guardcore.Engine) RedisStatus { + status := RedisStatus{Enabled: engine.Redis.Enabled(), OK: true} + if !status.Enabled { + return status + } + if _, err := engine.Redis.PTTL(engine.Redis.Prefix() + redisStatusProbeKeySuffix); err != nil { + message := err.Error() + status.OK = false + status.Error = &message + } + return status +} + +// NewStatusHandler returns the echo handler serving the engine's +// initialization status JSON (the reference guard_initialization_status). +// Mount it behind the security middleware like any other route and exclude +// it from the checks via the config's path exclusions when it should answer +// unguarded. +func NewStatusHandler(engine *guardcore.Engine) (echolib.HandlerFunc, error) { + if engine == nil { + return nil, errors.New("engine must not be nil") + } + return func(c echolib.Context) error { + return c.JSON(http.StatusOK, initializationStatus(engine)) + }, nil +} + +// AddStatusRoute registers the status handler on the echo instance at path +// (the empty string picks DefaultStatusPath), mirroring fastapi-guard +// add_status_route: +// +// if err := echo.AddStatusRoute(app, engine, ""); err != nil { ... } +// +// The method-scoped route answers GET only; other methods fall through to +// the framework's not-found handling. +func AddStatusRoute(app *echolib.Echo, engine *guardcore.Engine, path string) error { + if app == nil { + return errors.New("app must not be nil") + } + handler, err := NewStatusHandler(engine) + if err != nil { + return err + } + if path == "" { + path = DefaultStatusPath + } + app.GET(path, handler) + return nil +} diff --git a/status_test.go b/status_test.go new file mode 100644 index 0000000..6f808c9 --- /dev/null +++ b/status_test.go @@ -0,0 +1,229 @@ +package echo + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + echolib "github.com/labstack/echo/v4" + "github.com/rennf93/guard-core-go/v4/guardcore" +) + +// fakeGeoResolver is a foreign CountryResolver carrying the optional +// get_status surface the status route picks up (the built-in GeoIPManager's +// GetStatus shape). +type fakeGeoResolver struct { + status map[string]any +} + +func (f *fakeGeoResolver) GetCountry(ip string) (string, bool) { return "", false } + +func (f *fakeGeoResolver) GetStatus() map[string]any { return f.status } + +func statusServe(t *testing.T, mutate func(*guardcore.SecurityConfig), path string) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + engine := newTestEngine(t, mutate) + app := echolib.New() + if err := AddStatusRoute(app, engine, path); err != nil { + t.Fatalf("add status route: %v", err) + } + served := path + if served == "" { + served = DefaultStatusPath + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, served, nil) + app.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status route must answer 200, got %d", rec.Code) + } + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("status payload must be JSON: %v", err) + } + return rec, payload +} + +func TestAddStatusRouteNilEngineRejected(t *testing.T) { + if err := AddStatusRoute(echolib.New(), nil, ""); err == nil { + t.Fatal("nil engine must be rejected") + } + if _, err := NewStatusHandler(nil); err == nil { + t.Fatal("nil engine must be rejected") + } +} + +func TestAddStatusRouteNilAppRejected(t *testing.T) { + engine := newTestEngine(t, nil) + if err := AddStatusRoute(nil, engine, ""); err == nil { + t.Fatal("nil app must be rejected") + } +} + +func TestStatusRouteDefaultPath(t *testing.T) { + rec, payload := statusServe(t, nil, "") + if _, ok := payload["cloud_providers"]; !ok { + t.Fatal("payload must carry cloud_providers") + } + if got := rec.Header().Get("Content-Type"); got == "" { + t.Fatal("status route must serve a content type") + } +} + +func TestStatusRouteCustomPath(t *testing.T) { + _, payload := statusServe(t, nil, "/health/guard") + if _, ok := payload["geo_ip"]; !ok { + t.Fatal("payload must carry geo_ip") + } +} + +func TestStatusRouteCloudProvidersPayload(t *testing.T) { + _, payload := statusServe(t, nil, "") + raw, ok := payload["cloud_providers"].(map[string]any) + if !ok { + t.Fatal("cloud_providers must be an object") + } + if len(raw) == 0 { + t.Fatal("cloud_providers must list the reference provider table") + } + for provider, entry := range raw { + row, ok := entry.(map[string]any) + if !ok { + t.Fatalf("provider %s must be an object", provider) + } + if ready, ok := row["ready"].(bool); !ok || ready { + t.Fatalf("provider %s must report ready=false before any refresh", provider) + } + if entries, ok := row["entries"].(float64); !ok || entries != 0 { + t.Fatalf("provider %s must report entries=0 before any refresh, got %v", provider, entries) + } + if refreshed, ok := row["last_refreshed"]; !ok || refreshed != nil { + t.Fatalf("provider %s must report last_refreshed=null before any refresh, got %v", provider, refreshed) + } + } +} + +func TestStatusRouteGeoIPNullWithoutHandler(t *testing.T) { + _, payload := statusServe(t, nil, "") + if got, ok := payload["geo_ip"]; !ok || got != nil { + t.Fatalf("geo_ip must be null without a configured handler, got %v", got) + } +} + +func TestStatusRouteGeoIPHandlerStatus(t *testing.T) { + stub := &fakeGeoResolver{status: map[string]any{ + "ready": true, + "last_refreshed": "2026-10-06T00:00:00Z", + "entries": 7, + }} + _, payload := statusServe(t, func(cfg *guardcore.SecurityConfig) { + cfg.GeoIPHandler = stub + }, "") + geo, ok := payload["geo_ip"].(map[string]any) + if !ok { + t.Fatalf("geo_ip must serve the handler's get_status snapshot, got %v", payload["geo_ip"]) + } + if ready, ok := geo["ready"].(bool); !ok || !ready { + t.Fatalf("geo_ip.ready must pass the handler snapshot through, got %v", geo["ready"]) + } +} + +func TestStatusRouteRedisDisabledReportsOK(t *testing.T) { + _, payload := statusServe(t, nil, "") + redis, ok := payload["redis"].(map[string]any) + if !ok { + t.Fatal("redis must be an object") + } + if enabled, _ := redis["enabled"].(bool); enabled { + t.Fatal("redis must report enabled=false on a redisless engine") + } + if ok, _ := redis["ok"].(bool); !ok { + t.Fatal("a disabled redis reports ok=true (there is nothing to be down)") + } +} + +func TestStatusRouteRedisUnreachable(t *testing.T) { + // A refused dial fails immediately (no dial-timeout wait); the lazy + // probe reports the failure instead of pretending to be healthy. + _, payload := statusServe(t, func(cfg *guardcore.SecurityConfig) { + cfg.EnableRedis = true + cfg.RedisURL = "redis://127.0.0.1:1/0" + }, "") + redis, ok := payload["redis"].(map[string]any) + if !ok { + t.Fatal("redis must be an object") + } + if enabled, ok := redis["enabled"].(bool); !ok || !enabled { + t.Fatalf("redis.enabled must mirror the config, got %v", redis["enabled"]) + } + if ok, _ := redis["ok"].(bool); ok { + t.Fatalf("redis.ok must be false when the probe cannot reach redis, got %v", redis) + } + if err, ok := redis["error"].(string); !ok || err == "" { + t.Fatalf("redis.error must carry the probe failure, got %v", redis["error"]) + } +} + +func TestStatusHandlerDirect(t *testing.T) { + engine := newTestEngine(t, nil) + handler, err := NewStatusHandler(engine) + if err != nil { + t.Fatalf("new status handler: %v", err) + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/anywhere", nil) + c := echolib.New().NewContext(req, rec) + if err := handler(c); err != nil { + t.Fatalf("handler must serve without error, got %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("handler must answer 200 on any mount, got %d", rec.Code) + } + if !bytes.HasPrefix(rec.Body.Bytes(), []byte("{")) { + t.Fatalf("handler must serve a JSON object, got %q", rec.Body.String()) + } +} + +func TestStatusRouteCloudProviderRefreshSnapshot(t *testing.T) { + engine := newTestEngine(t, nil) + // A private manager (Engine.Cloud is an exported field): the package + // default is a process global, and this test must not leak readiness + // into the sibling assertions. + engine.Cloud = guardcore.NewCloudManager() + engine.Cloud.SetRangeFetcher(func(provider string) ([]string, map[string]string, error) { + return []string{"203.0.113.0/24"}, nil, nil + }) + if err := engine.Cloud.RefreshAsync([]string{"AWS"}, 300); err != nil { + t.Fatalf("refresh: %v", err) + } + app := echolib.New() + if err := AddStatusRoute(app, engine, ""); err != nil { + t.Fatalf("add status route: %v", err) + } + rec := httptest.NewRecorder() + app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, DefaultStatusPath, nil)) + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("status payload must be JSON: %v", err) + } + raw := payload["cloud_providers"].(map[string]any) + aws, ok := raw["AWS"].(map[string]any) + if !ok { + t.Fatalf("AWS must be a row, got %v", raw) + } + if ready, ok := aws["ready"].(bool); !ok || !ready { + t.Fatalf("a refreshed provider must report ready=true, got %v", aws["ready"]) + } + if refreshed, ok := aws["last_refreshed"].(string); !ok || refreshed == "" { + t.Fatalf("a refreshed provider must carry a last_refreshed stamp, got %v", aws["last_refreshed"]) + } + gcp, ok := raw["GCP"].(map[string]any) + if !ok { + t.Fatalf("GCP must be a row, got %v", raw) + } + if refreshed, ok := gcp["last_refreshed"]; !ok || refreshed != nil { + t.Fatalf("a never-refreshed provider must report last_refreshed=null, got %v", refreshed) + } +}