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
153 changes: 153 additions & 0 deletions status.go
Original file line number Diff line number Diff line change
@@ -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
}
229 changes: 229 additions & 0 deletions status_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading