package middleware import ( "context" "errors" "net/http" "net/http/httptest" "strings" "testing" "github.com/go-chi/chi/v5" "github.com/sarathsp06/sparrow/internal/accessauth" "github.com/sarathsp06/sparrow/pkg/access" "github.com/sarathsp06/sparrow/pkg/access/memstore" ) func newPortalService(t *testing.T) *access.Service { t.Helper() svc, err := access.New(access.Config{Store: memstore.New(), TokenPrefix: "sparrow_tk_"}) if err != nil { t.Fatal(err) } return svc } // mintConsumerToken returns the secret of a new token scoped to consumer. func mintConsumerToken(t *testing.T, svc *access.Service, consumer string) string { t.Helper() _, secret, err := svc.CreateToken(context.Background(), access.CreateTokenRequest{Realm: testRealm, Scope: &consumer, Name: "portal", CreatedBy: "test"}) if err != nil { t.Fatal(err) } return secret } func newPortalGateway(t *testing.T) (*access.Service, http.Handler, *string) { t.Helper() svc := newPortalService(t) seen := new(string) next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !PortalAuthorized(r.Context()) { w.WriteHeader(http.StatusForbidden) return } *seen = r.URL.Path if r.URL.RawQuery != "" { *seen += "?" + r.URL.RawQuery } w.WriteHeader(http.StatusNoContent) }) return svc, PortalGateway(NewPortalVerifier(svc, accessauth.Realm), PortalOptions{Retry: true}, next), seen } func doBearer(handler http.Handler, method, path, token string) int { req := httptest.NewRequest(method, path, nil) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) return rr.Code } func TestPortalGatewayScopesToTokenConsumer(t *testing.T) { svc, handler, seen := newPortalGateway(t) token := mintConsumerToken(t, svc, "acme") // Every allowed call rewrites to a /v1 path scoped to the token's consumer. // The consumer never appears in the request URL, so cross-consumer access // is structurally impossible — there is nothing to test for "other". cases := []struct { method, path string want int target string }{ {http.MethodGet, "/portal/api/webhooks", http.StatusNoContent, "/v1/consumers/acme/webhooks"}, {http.MethodPost, "/portal/api/subscriptions", http.StatusNoContent, "/v1/consumers/acme/subscriptions"}, {http.MethodPost, "/portal/api/alert-configs", http.StatusNoContent, "/v1/consumers/acme/alert-configs"}, {http.MethodGet, "/portal/api/deliveries", http.StatusNoContent, "/v1/consumers/acme/deliveries"}, {http.MethodPost, "/portal/api/deliveries/abc:retry", http.StatusNoContent, "/v1/consumers/acme/deliveries/abc:retry"}, {http.MethodGet, "/portal/api/events", http.StatusNoContent, "/v1/consumers/acme/events"}, // Test sends act on the token consumer's own subscriptions. {http.MethodPost, "/portal/api/subscriptions/sub1:sendTestEvent", http.StatusNoContent, "/v1/consumers/acme/subscriptions/sub1:sendTestEvent"}, // Event injection is denied for portal tokens (token minting lives // under the global /v1/tokens, which no portal path maps to). {http.MethodPost, "/portal/api/events", http.StatusForbidden, ""}, // Re-push is the producer's job too: the action, its jobs, and the // snapshot that feeds it. {http.MethodPost, "/portal/api/events:rePush", http.StatusForbidden, ""}, {http.MethodGet, "/portal/api/repush-jobs/job1", http.StatusForbidden, ""}, {http.MethodPost, "/portal/api/repush-jobs/job1:cancel", http.StatusForbidden, ""}, {http.MethodGet, "/portal/api/events?prepare_repush=true", http.StatusForbidden, ""}, // Retry snapshots are allowed while retries are (see TestPortalGatewayRetryOff). {http.MethodGet, "/portal/api/deliveries?prepare_retry=true", http.StatusNoContent, "/v1/consumers/acme/deliveries?prepare_retry=true"}, {http.MethodPost, "/portal/api/tokens", http.StatusNoContent, "/v1/consumers/acme/tokens"}, // Read-only global helpers the portal UI needs: allowed, not consumer-scoped. // The event-type list is pinned to the token's consumer, whatever the // client asks for. {http.MethodGet, "/portal/api/event-types", http.StatusNoContent, "/v1/event-types?consumer=acme"}, {http.MethodGet, "/portal/api/event-types?consumer=_sparrow&active_only=true", http.StatusNoContent, "/v1/event-types?active_only=true&consumer=acme"}, {http.MethodGet, "/portal/api/event-types/order.created", http.StatusNoContent, "/v1/event-types/order.created"}, {http.MethodGet, "/portal/api/template-functions", http.StatusNoContent, "/v1/template-functions"}, {http.MethodGet, "/portal/api/capabilities", http.StatusNoContent, "/v1/capabilities"}, {http.MethodPost, "/portal/api/subscriptions:testTemplate", http.StatusNoContent, "/v1/subscriptions:testTemplate"}, // Dot segments, empty segments and backslashes never reach the router, // so the consumer pin can't be escaped even if paths get cleaned later. {http.MethodGet, "/portal/api/../other/webhooks", http.StatusForbidden, ""}, {http.MethodGet, "/portal/api/webhooks/../../other/webhooks", http.StatusForbidden, ""}, {http.MethodGet, "/portal/api/./webhooks", http.StatusForbidden, ""}, {http.MethodGet, "/portal/api/webhooks//x", http.StatusForbidden, ""}, {http.MethodGet, "/portal/api/..%5Cother", http.StatusForbidden, ""}, {http.MethodGet, "/portal/api/", http.StatusForbidden, ""}, {http.MethodGet, "/portal/api/webhooks/", http.StatusNoContent, "/v1/consumers/acme/webhooks/"}, } for _, c := range cases { *seen = "" if got := doBearer(handler, c.method, c.path, token); got != c.want { t.Errorf("%s %s = %d, want %d", c.method, c.path, got, c.want) } if c.want == http.StatusNoContent && *seen != c.target { t.Errorf("%s %s rewrote to %q, want %q", c.method, c.path, *seen, c.target) } } } // SPARROW_PORTAL_RETRY=false: the consumer still reads its deliveries and // retry jobs, but every way of sending a delivery again is refused. func TestPortalGatewayRetryOff(t *testing.T) { svc := newPortalService(t) token := mintConsumerToken(t, svc, "acme") next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) gw := PortalGateway(NewPortalVerifier(svc, accessauth.Realm), PortalOptions{Retry: false}, next) cases := []struct { method, path string want int }{ {http.MethodPost, "/portal/api/deliveries/abc:retry", http.StatusForbidden}, {http.MethodPost, "/portal/api/deliveries:retry", http.StatusForbidden}, {http.MethodPost, "/portal/api/deliveries:retryBatch", http.StatusForbidden}, {http.MethodGet, "/portal/api/deliveries?prepare_retry=true", http.StatusForbidden}, {http.MethodGet, "/portal/api/deliveries", http.StatusNoContent}, {http.MethodGet, "/portal/api/deliveries/abc", http.StatusNoContent}, {http.MethodGet, "/portal/api/deliveries/abc/attempts", http.StatusNoContent}, {http.MethodGet, "/portal/api/retry-jobs/job1", http.StatusNoContent}, {http.MethodPost, "/portal/api/retry-jobs/job1:cancel", http.StatusNoContent}, } for _, c := range cases { if got := doBearer(gw, c.method, c.path, token); got != c.want { t.Errorf("%s %s = %d, want %d", c.method, c.path, got, c.want) } } } func TestPortalGatewayRejectsGarbageToken(t *testing.T) { _, handler, _ := newPortalGateway(t) if got := doBearer(handler, http.MethodGet, "/portal/api/webhooks", "sparrow_tk_garbage"); got != http.StatusUnauthorized { t.Fatalf("garbage token status = %d, want 401", got) } if got := doBearer(handler, http.MethodGet, "/portal/api/webhooks", ""); got != http.StatusUnauthorized { t.Fatalf("no credentials status = %d, want 401", got) } } // End-to-end: the gateway re-dispatches into the real chi router, so a portal // call must reach the admin-key-protected /v1 handler with {consumer} resolved // from the token — proving the fresh route context and the auth-bypass flag // both work through an actual router (the wiring in cmd/server/main.go). func TestPortalGatewayReDispatchesThroughRouter(t *testing.T) { auth, svc := newAuth(t, "admin-secret") r := chi.NewRouter() r.Group(func(gr chi.Router) { gr.Use(auth.HTTPMiddleware) gr.Get("/v1/consumers/{consumer}/webhooks", func(w http.ResponseWriter, req *http.Request) { _, _ = w.Write([]byte(chi.URLParam(req, "consumer"))) }) }) r.Handle("/portal/api/*", PortalGateway(NewPortalVerifier(svc, accessauth.Realm), PortalOptions{Retry: true}, r)) token := mintConsumerToken(t, svc, "acme") // Portal call: no admin key, only the bearer token. req := httptest.NewRequest(http.MethodGet, "/portal/api/webhooks", nil) req.Header.Set("Authorization", "Bearer "+token) rr := httptest.NewRecorder() r.ServeHTTP(rr, req) if rr.Code != http.StatusOK || rr.Body.String() != "acme" { t.Fatalf("portal call = %d %q, want 200 \"acme\"", rr.Code, rr.Body.String()) } // Same /v1 route without a key is still rejected (bypass is token-only). direct := httptest.NewRequest(http.MethodGet, "/v1/consumers/acme/webhooks", nil) rr2 := httptest.NewRecorder() r.ServeHTTP(rr2, direct) if rr2.Code != http.StatusUnauthorized { t.Fatalf("keyless /v1 call = %d, want 401", rr2.Code) } } // downStore simulates a token store outage. type downStore struct{ access.Store } func (downStore) TokenByHash(context.Context, []byte) (access.Token, error) { return access.Token{}, errors.New("db down") } func TestPortalGatewayAcceptsConsumerAccessTokens(t *testing.T) { svc := newPortalService(t) seen := new(string) gw := PortalGateway(NewPortalVerifier(svc, accessauth.Realm), PortalOptions{Retry: true}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { *seen = r.URL.Path if r.URL.RawQuery != "" { *seen += "?" + r.URL.RawQuery } w.WriteHeader(http.StatusNoContent) })) ctx := context.Background() acme := "acme" consumerTok, consumerSecret, _ := svc.CreateToken(ctx, access.CreateTokenRequest{Realm: testRealm, Scope: &acme, Name: "acme staff", CreatedBy: "x"}) if code := doBearer(gw, http.MethodGet, "/portal/api/webhooks", consumerSecret); code != http.StatusNoContent || *seen != "/v1/consumers/acme/webhooks" { t.Fatalf("consumer token: %d %q", code, *seen) } // The portal allow-list still applies to access tokens. if code := doBearer(gw, http.MethodPost, "/portal/api/events", consumerSecret); code != http.StatusForbidden { t.Fatalf("event push via portal: %d, want 403", code) } _, fullSecret, _ := svc.CreateToken(ctx, access.CreateTokenRequest{Realm: testRealm, Name: "admin", CreatedBy: "x"}) if code := doBearer(gw, http.MethodGet, "/portal/api/webhooks", fullSecret); code != http.StatusForbidden { t.Fatalf("full-access token via portal: %d, want 403", code) } _, foreignSecret, _ := svc.CreateToken(ctx, access.CreateTokenRequest{Realm: "other", Scope: &acme, Name: "x", CreatedBy: "x"}) if code := doBearer(gw, http.MethodGet, "/portal/api/webhooks", foreignSecret); code != http.StatusUnauthorized { t.Fatalf("foreign realm: %d, want 401", code) } _ = svc.RevokeToken(ctx, testRealm, consumerTok.ID) req := httptest.NewRequest(http.MethodGet, "/portal/api/webhooks", nil) req.Header.Set("Authorization", "Bearer "+consumerSecret) rr := httptest.NewRecorder() gw.ServeHTTP(rr, req) if rr.Code != http.StatusUnauthorized || !strings.Contains(rr.Body.String(), `"reason":"revoked"`) { t.Fatalf("revoked: %d %s", rr.Code, rr.Body) } } func TestPortalGatewayStoreOutageIs503(t *testing.T) { svc, _ := access.New(access.Config{Store: downStore{memstore.New()}, TokenPrefix: "sparrow_tk_"}) gw := PortalGateway(NewPortalVerifier(svc, accessauth.Realm), PortalOptions{Retry: true}, http.NotFoundHandler()) if code := doBearer(gw, http.MethodGet, "/portal/api/webhooks", "sparrow_tk_anything"); code != http.StatusServiceUnavailable { t.Fatalf("outage: %d, want 503", code) } } func TestPortalGatewayRejectsUnsafeTokenConsumers(t *testing.T) { // pkg/access does not validate scope names (the REST layer does); the // gateway must still never build a multi-segment /v1 path from one. svc, gw, seen := newPortalGateway(t) for _, consumer := range []string{"a/../b", "..", "a%2Fb"} { token := mintConsumerToken(t, svc, consumer) *seen = "" if code := doBearer(gw, http.MethodGet, "/portal/api/webhooks", token); code != http.StatusForbidden || *seen != "" { t.Errorf("consumer %q = %d (reached %q), want 403", consumer, code, *seen) } } }