Ctrl K
ComunidadGuías…

internal/middleware/portal_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)
		}
	}
}