From f76ce7410cc32253f483f1fef1dcd3d66d3d8074 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Fri, 30 Jan 2026 13:09:49 +0700 Subject: [PATCH 01/34] fix: return 401 for malformed bearer token + add expired JWT tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ErrInvalidBearerToken now returns 401 instead of 400 — a malformed bearer token is an authentication failure, not a bad request. Add expired JWT tests for Extract, TenantJWTAuthMiddleware, and APIKeyOrTenantJWTAuthMiddleware. Co-Authored-By: Claude Opus 4.5 --- internal/apirouter/auth_middleware.go | 12 ----- internal/apirouter/auth_middleware_test.go | 52 ++++++++++++++++++++-- internal/apirouter/jwt_test.go | 17 +++++++ 3 files changed, 66 insertions(+), 15 deletions(-) diff --git a/internal/apirouter/auth_middleware.go b/internal/apirouter/auth_middleware.go index 5fd12bfc..52b96efc 100644 --- a/internal/apirouter/auth_middleware.go +++ b/internal/apirouter/auth_middleware.go @@ -61,10 +61,6 @@ func APIKeyAuthMiddleware(apiKey string) gin.HandlerFunc { return func(c *gin.Context) { token, err := validateAuthHeader(c) if err != nil { - if errors.Is(err, ErrInvalidBearerToken) { - c.AbortWithStatus(http.StatusBadRequest) - return - } c.AbortWithStatus(http.StatusUnauthorized) return } @@ -91,10 +87,6 @@ func APIKeyOrTenantJWTAuthMiddleware(apiKey string, jwtKey string) gin.HandlerFu return func(c *gin.Context) { token, err := validateAuthHeader(c) if err != nil { - if errors.Is(err, ErrInvalidBearerToken) { - c.AbortWithStatus(http.StatusBadRequest) - return - } c.AbortWithStatus(http.StatusUnauthorized) return } @@ -135,10 +127,6 @@ func TenantJWTAuthMiddleware(apiKey string, jwtKey string) gin.HandlerFunc { token, err := validateAuthHeader(c) if err != nil { - if errors.Is(err, ErrInvalidBearerToken) { - c.AbortWithStatus(http.StatusBadRequest) - return - } c.AbortWithStatus(http.StatusUnauthorized) return } diff --git a/internal/apirouter/auth_middleware_test.go b/internal/apirouter/auth_middleware_test.go index a6654d53..10a2871c 100644 --- a/internal/apirouter/auth_middleware_test.go +++ b/internal/apirouter/auth_middleware_test.go @@ -4,8 +4,10 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/hookdeck/outpost/internal/apirouter" @@ -64,7 +66,7 @@ func TestPrivateAPIKeyRouter(t *testing.T) { req, _ := http.NewRequest("PUT", baseAPIPath+"/tenants/tenant_id", nil) req.Header.Set("Authorization", "invalid key") router.ServeHTTP(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("should reject requests with an incorrect authorization token", func(t *testing.T) { @@ -230,6 +232,27 @@ func TestAPIKeyOrTenantJWTAuthMiddleware(t *testing.T) { assert.Equal(t, tenantID, contextTenantID) }) + t.Run("should reject expired JWT token", func(t *testing.T) { + t.Parallel() + + // Setup + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + // Create expired JWT token + token := newExpiredJWTToken(t, jwtSecret, tenantID) + + // Set auth header + c.Request = httptest.NewRequest("GET", "/", nil) + c.Request.Header.Set("Authorization", "Bearer "+token) + + // Test + handler := apirouter.APIKeyOrTenantJWTAuthMiddleware(apiKey, jwtSecret) + handler(c) + + assert.Equal(t, http.StatusUnauthorized, c.Writer.Status()) + }) + t.Run("should accept when using API key regardless of tenantID param", func(t *testing.T) { t.Parallel() @@ -258,6 +281,21 @@ func newJWTToken(t *testing.T, secret string, tenantID string) string { return token } +func newExpiredJWTToken(t *testing.T, secret string, tenantID string) string { + now := time.Now() + jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "iss": "outpost", + "sub": tenantID, + "iat": now.Add(-2 * time.Hour).Unix(), + "exp": now.Add(-1 * time.Hour).Unix(), + }) + token, err := jwtToken.SignedString([]byte(secret)) + if err != nil { + t.Fatal(err) + } + return token +} + func TestTenantJWTAuthMiddleware(t *testing.T) { gin.SetMode(gin.TestMode) t.Parallel() @@ -293,11 +331,11 @@ func TestTenantJWTAuthMiddleware(t *testing.T) { wantTenantID: "", }, { - name: "should return 400 when invalid auth header", + name: "should return 401 when invalid auth header", apiKey: "key", jwtSecret: "secret", header: "invalid", - wantStatus: http.StatusBadRequest, + wantStatus: http.StatusUnauthorized, wantTenantID: "", }, { @@ -325,6 +363,14 @@ func TestTenantJWTAuthMiddleware(t *testing.T) { wantStatus: http.StatusUnauthorized, wantTenantID: "", }, + { + name: "should return 401 when token is expired", + apiKey: "key", + jwtSecret: "secret", + header: "Bearer " + newExpiredJWTToken(t, "secret", "tenant-id"), + wantStatus: http.StatusUnauthorized, + wantTenantID: "", + }, } for _, tt := range tests { diff --git a/internal/apirouter/jwt_test.go b/internal/apirouter/jwt_test.go index 158a5c93..adda8e46 100644 --- a/internal/apirouter/jwt_test.go +++ b/internal/apirouter/jwt_test.go @@ -173,4 +173,21 @@ func TestJWT(t *testing.T) { assert.ErrorIs(t, err, apirouter.ErrInvalidToken) assert.False(t, valid) }) + + t.Run("should fail to extract claims from expired token", func(t *testing.T) { + t.Parallel() + now := time.Now() + jwtToken := jwt.NewWithClaims(signingMethod, jwt.MapClaims{ + "iss": issuer, + "sub": tenantID, + "iat": now.Add(-2 * time.Hour).Unix(), + "exp": now.Add(-24 * time.Hour).Unix(), + }) + token, err := jwtToken.SignedString([]byte(jwtKey)) + if err != nil { + t.Fatal(err) + } + _, err = apirouter.JWT.Extract(jwtKey, token) + assert.ErrorIs(t, err, apirouter.ErrInvalidToken) + }) } From 0a1c84f09de8d4d716f1aee2abe94134af830f81 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Fri, 30 Jan 2026 13:30:24 +0700 Subject: [PATCH 02/34] refactor: introduce AuthMode + TenantScoped, flatten route list Replace AuthScope/RouteMode with simpler AuthMode enum and TenantScoped bool. Flatten 5 route slices into 2 (nonTenantRoutes + tenantRoutes) with portal routes conditionally appended. Auto-apply RequireTenantMiddleware via TenantScoped instead of manual Middlewares arrays. Define narrow TenantRetriever interface to decouple middleware from full TenantStore. Change mustTenantFromContext to panic on missing tenant (programming bug, not user error). Co-Authored-By: Claude Opus 4.5 --- .../apirouter/requiretenant_middleware.go | 16 +- internal/apirouter/router.go | 371 ++++-------------- 2 files changed, 80 insertions(+), 307 deletions(-) diff --git a/internal/apirouter/requiretenant_middleware.go b/internal/apirouter/requiretenant_middleware.go index 98dbe9d6..df080268 100644 --- a/internal/apirouter/requiretenant_middleware.go +++ b/internal/apirouter/requiretenant_middleware.go @@ -1,16 +1,21 @@ package apirouter import ( + "context" "net/http" - "errors" - "github.com/gin-gonic/gin" "github.com/hookdeck/outpost/internal/models" "github.com/hookdeck/outpost/internal/tenantstore" ) -func RequireTenantMiddleware(tenantStore tenantstore.TenantStore) gin.HandlerFunc { +// TenantRetriever is satisfied by tenantstore.TenantStore. +// Defined here to avoid coupling the router/middleware to the full store interface. +type TenantRetriever interface { + RetrieveTenant(ctx context.Context, tenantID string) (*models.Tenant, error) +} + +func RequireTenantMiddleware(tenantRetriever TenantRetriever) gin.HandlerFunc { return func(c *gin.Context) { tenantID, exists := c.Get("tenantID") if !exists { @@ -18,7 +23,7 @@ func RequireTenantMiddleware(tenantStore tenantstore.TenantStore) gin.HandlerFun return } - tenant, err := tenantStore.RetrieveTenant(c.Request.Context(), tenantID.(string)) + tenant, err := tenantRetriever.RetrieveTenant(c.Request.Context(), tenantID.(string)) if err != nil { if err == tenantstore.ErrTenantDeleted { c.AbortWithStatus(http.StatusNotFound) @@ -39,8 +44,7 @@ func RequireTenantMiddleware(tenantStore tenantstore.TenantStore) gin.HandlerFun func mustTenantFromContext(c *gin.Context) *models.Tenant { tenant, ok := c.Get("tenant") if !ok { - AbortWithError(c, http.StatusInternalServerError, errors.New("tenant not found in context")) - return nil + panic("mustTenantFromContext: tenant not found in context - route is likely missing TenantScoped: true") } return tenant.(*models.Tenant) } diff --git a/internal/apirouter/router.go b/internal/apirouter/router.go index a6279073..c593538f 100644 --- a/internal/apirouter/router.go +++ b/internal/apirouter/router.go @@ -21,28 +21,21 @@ import ( "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" ) -type AuthScope string +type AuthMode string const ( - AuthScopeAdmin AuthScope = "admin" - AuthScopeTenant AuthScope = "tenant" - AuthScopeAdminOrTenant AuthScope = "admin_or_tenant" -) - -type RouteMode string - -const ( - RouteModeAlways RouteMode = "always" // Register route regardless of mode - RouteModePortal RouteMode = "portal" // Only register when portal is enabled (both apiKey and jwtSecret set) + AuthPublic AuthMode = "public" + AuthTenant AuthMode = "tenant" + AuthAdmin AuthMode = "admin" ) type RouteDefinition struct { - Method string - Path string - Handler gin.HandlerFunc - AuthScope AuthScope - Mode RouteMode - Middlewares []gin.HandlerFunc + Method string + Path string + Handler gin.HandlerFunc + AuthMode AuthMode + TenantScoped bool + Middlewares []gin.HandlerFunc } type RouterConfig struct { @@ -56,32 +49,34 @@ type RouterConfig struct { GinMode string } -// registerRoutes registers routes to the given router based on route definitions and config -func registerRoutes(router *gin.RouterGroup, cfg RouterConfig, routes []RouteDefinition) { - isPortalMode := cfg.APIKey != "" && cfg.JWTSecret != "" +func (c RouterConfig) PortalEnabled() bool { + return c.APIKey != "" && c.JWTSecret != "" +} +// registerRoutes registers routes to the given router based on route definitions and config +func registerRoutes(router *gin.RouterGroup, cfg RouterConfig, tenantRetriever TenantRetriever, routes []RouteDefinition) { for _, route := range routes { - // Skip portal routes if not in portal mode - if route.Mode == RouteModePortal && !isPortalMode { - continue - } - - handlers := buildMiddlewareChain(cfg, route) + handlers := buildMiddlewareChain(cfg, tenantRetriever, route) router.Handle(route.Method, route.Path, handlers...) } } -func buildMiddlewareChain(cfg RouterConfig, def RouteDefinition) []gin.HandlerFunc { +func buildMiddlewareChain(cfg RouterConfig, tenantRetriever TenantRetriever, def RouteDefinition) []gin.HandlerFunc { chain := make([]gin.HandlerFunc, 0) - // Add auth middleware based on scope - switch def.AuthScope { - case AuthScopeAdmin: + // Add auth middleware based on mode + switch def.AuthMode { + case AuthAdmin: chain = append(chain, APIKeyAuthMiddleware(cfg.APIKey)) - case AuthScopeTenant: - chain = append(chain, TenantJWTAuthMiddleware(cfg.APIKey, cfg.JWTSecret)) - case AuthScopeAdminOrTenant: + case AuthTenant: chain = append(chain, APIKeyOrTenantJWTAuthMiddleware(cfg.APIKey, cfg.JWTSecret)) + case AuthPublic: + // no auth middleware + } + + // Auto-apply tenant middleware when route is tenant-scoped + if def.TenantScoped { + chain = append(chain, RequireTenantMiddleware(tenantRetriever)) } // Add custom middlewares @@ -148,290 +143,64 @@ func NewRouter( // Non-tenant routes (no :tenantID in path) nonTenantRoutes := []RouteDefinition{ - { - Method: http.MethodPost, - Path: "/publish", - Handler: publishHandlers.Ingest, - AuthScope: AuthScopeAdmin, - Mode: RouteModeAlways, - }, - { - Method: http.MethodGet, - Path: "/tenants", - Handler: tenantHandlers.List, - AuthScope: AuthScopeAdmin, - Mode: RouteModeAlways, - }, - { - Method: http.MethodGet, - Path: "/events", - Handler: logHandlers.AdminListEvents, - AuthScope: AuthScopeAdmin, - Mode: RouteModeAlways, - }, - { - Method: http.MethodGet, - Path: "/attempts", - Handler: logHandlers.AdminListAttempts, - AuthScope: AuthScopeAdmin, - Mode: RouteModeAlways, - }, - } - - // Tenant upsert route (admin-only, but has :tenantID in path) - tenantUpsertRoute := RouteDefinition{ - Method: http.MethodPut, - Path: "/:tenantID", - Handler: tenantHandlers.Upsert, - AuthScope: AuthScopeAdmin, - Mode: RouteModeAlways, - } - - // Portal routes - portalRoutes := []RouteDefinition{ - { - Method: http.MethodGet, - Path: "/:tenantID/token", - Handler: tenantHandlers.RetrieveToken, - AuthScope: AuthScopeAdmin, - Mode: RouteModePortal, - Middlewares: []gin.HandlerFunc{ - RequireTenantMiddleware(tenantStore), - }, - }, - { - Method: http.MethodGet, - Path: "/:tenantID/portal", - Handler: tenantHandlers.RetrievePortal, - AuthScope: AuthScopeAdmin, - Mode: RouteModePortal, - Middlewares: []gin.HandlerFunc{ - RequireTenantMiddleware(tenantStore), - }, - }, + {Method: http.MethodPost, Path: "/publish", Handler: publishHandlers.Ingest, AuthMode: AuthAdmin}, + {Method: http.MethodGet, Path: "/tenants", Handler: tenantHandlers.List, AuthMode: AuthAdmin}, + {Method: http.MethodGet, Path: "/events", Handler: logHandlers.AdminListEvents, AuthMode: AuthAdmin}, + {Method: http.MethodGet, Path: "/attempts", Handler: logHandlers.AdminListAttempts, AuthMode: AuthAdmin}, } - // Routes that work with both auth methods - tenantAgnosticRoutes := []RouteDefinition{ - { - Method: http.MethodGet, - Path: "/:tenantID/destination-types", - Handler: destinationHandlers.ListProviderMetadata, - AuthScope: AuthScopeAdminOrTenant, - Mode: RouteModeAlways, - }, - { - Method: http.MethodGet, - Path: "/:tenantID/destination-types/:type", - Handler: destinationHandlers.RetrieveProviderMetadata, - AuthScope: AuthScopeAdminOrTenant, - Mode: RouteModeAlways, - }, - { - Method: http.MethodGet, - Path: "/:tenantID/topics", - Handler: topicHandlers.List, - AuthScope: AuthScopeAdminOrTenant, - Mode: RouteModeAlways, - }, - } + // Tenant routes (registered under /tenants group) + tenantRoutes := []RouteDefinition{ + // Tenant CRUD + {Method: http.MethodPut, Path: "/:tenantID", Handler: tenantHandlers.Upsert, AuthMode: AuthAdmin}, + {Method: http.MethodGet, Path: "/:tenantID", Handler: tenantHandlers.Retrieve, AuthMode: AuthTenant, TenantScoped: true}, + {Method: http.MethodDelete, Path: "/:tenantID", Handler: tenantHandlers.Delete, AuthMode: AuthTenant, TenantScoped: true}, - // Routes that require tenant context - tenantSpecificRoutes := []RouteDefinition{ - // Tenant routes - { - Method: http.MethodGet, - Path: "/:tenantID", - Handler: tenantHandlers.Retrieve, - AuthScope: AuthScopeAdminOrTenant, - Mode: RouteModeAlways, - Middlewares: []gin.HandlerFunc{ - RequireTenantMiddleware(tenantStore), - }, - }, - { - Method: http.MethodDelete, - Path: "/:tenantID", - Handler: tenantHandlers.Delete, - AuthScope: AuthScopeAdminOrTenant, - Mode: RouteModeAlways, - Middlewares: []gin.HandlerFunc{ - RequireTenantMiddleware(tenantStore), - }, - }, + // Tenant-agnostic routes (no tenant lookup needed) + {Method: http.MethodGet, Path: "/:tenantID/destination-types", Handler: destinationHandlers.ListProviderMetadata, AuthMode: AuthTenant}, + {Method: http.MethodGet, Path: "/:tenantID/destination-types/:type", Handler: destinationHandlers.RetrieveProviderMetadata, AuthMode: AuthTenant}, + {Method: http.MethodGet, Path: "/:tenantID/topics", Handler: topicHandlers.List, AuthMode: AuthTenant}, // Destination routes - { - Method: http.MethodGet, - Path: "/:tenantID/destinations", - Handler: destinationHandlers.List, - AuthScope: AuthScopeAdminOrTenant, - Mode: RouteModeAlways, - Middlewares: []gin.HandlerFunc{ - RequireTenantMiddleware(tenantStore), - }, - }, - { - Method: http.MethodPost, - Path: "/:tenantID/destinations", - Handler: destinationHandlers.Create, - AuthScope: AuthScopeAdminOrTenant, - Mode: RouteModeAlways, - Middlewares: []gin.HandlerFunc{ - RequireTenantMiddleware(tenantStore), - }, - }, - { - Method: http.MethodGet, - Path: "/:tenantID/destinations/:destinationID", - Handler: destinationHandlers.Retrieve, - AuthScope: AuthScopeAdminOrTenant, - Mode: RouteModeAlways, - Middlewares: []gin.HandlerFunc{ - RequireTenantMiddleware(tenantStore), - }, - }, - { - Method: http.MethodPatch, - Path: "/:tenantID/destinations/:destinationID", - Handler: destinationHandlers.Update, - AuthScope: AuthScopeAdminOrTenant, - Mode: RouteModeAlways, - Middlewares: []gin.HandlerFunc{ - RequireTenantMiddleware(tenantStore), - }, - }, - { - Method: http.MethodDelete, - Path: "/:tenantID/destinations/:destinationID", - Handler: destinationHandlers.Delete, - AuthScope: AuthScopeAdminOrTenant, - Mode: RouteModeAlways, - Middlewares: []gin.HandlerFunc{ - RequireTenantMiddleware(tenantStore), - }, - }, - { - Method: http.MethodPut, - Path: "/:tenantID/destinations/:destinationID/enable", - Handler: destinationHandlers.Enable, - AuthScope: AuthScopeAdminOrTenant, - Mode: RouteModeAlways, - Middlewares: []gin.HandlerFunc{ - RequireTenantMiddleware(tenantStore), - }, - }, - { - Method: http.MethodPut, - Path: "/:tenantID/destinations/:destinationID/disable", - Handler: destinationHandlers.Disable, - AuthScope: AuthScopeAdminOrTenant, - Mode: RouteModeAlways, - Middlewares: []gin.HandlerFunc{ - RequireTenantMiddleware(tenantStore), - }, - }, + {Method: http.MethodGet, Path: "/:tenantID/destinations", Handler: destinationHandlers.List, AuthMode: AuthTenant, TenantScoped: true}, + {Method: http.MethodPost, Path: "/:tenantID/destinations", Handler: destinationHandlers.Create, AuthMode: AuthTenant, TenantScoped: true}, + {Method: http.MethodGet, Path: "/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Retrieve, AuthMode: AuthTenant, TenantScoped: true}, + {Method: http.MethodPatch, Path: "/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Update, AuthMode: AuthTenant, TenantScoped: true}, + {Method: http.MethodDelete, Path: "/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Delete, AuthMode: AuthTenant, TenantScoped: true}, + {Method: http.MethodPut, Path: "/:tenantID/destinations/:destinationID/enable", Handler: destinationHandlers.Enable, AuthMode: AuthTenant, TenantScoped: true}, + {Method: http.MethodPut, Path: "/:tenantID/destinations/:destinationID/disable", Handler: destinationHandlers.Disable, AuthMode: AuthTenant, TenantScoped: true}, // Destination-scoped attempt routes - { - Method: http.MethodGet, - Path: "/:tenantID/destinations/:destinationID/attempts", - Handler: logHandlers.ListDestinationAttempts, - AuthScope: AuthScopeAdminOrTenant, - Mode: RouteModeAlways, - Middlewares: []gin.HandlerFunc{ - RequireTenantMiddleware(tenantStore), - }, - }, - { - Method: http.MethodGet, - Path: "/:tenantID/destinations/:destinationID/attempts/:attemptID", - Handler: logHandlers.RetrieveAttempt, - AuthScope: AuthScopeAdminOrTenant, - Mode: RouteModeAlways, - Middlewares: []gin.HandlerFunc{ - RequireTenantMiddleware(tenantStore), - }, - }, - { - Method: http.MethodPost, - Path: "/:tenantID/destinations/:destinationID/attempts/:attemptID/retry", - Handler: retryHandlers.RetryAttempt, - AuthScope: AuthScopeAdminOrTenant, - Mode: RouteModeAlways, - Middlewares: []gin.HandlerFunc{ - RequireTenantMiddleware(tenantStore), - }, - }, + {Method: http.MethodGet, Path: "/:tenantID/destinations/:destinationID/attempts", Handler: logHandlers.ListDestinationAttempts, AuthMode: AuthTenant, TenantScoped: true}, + {Method: http.MethodGet, Path: "/:tenantID/destinations/:destinationID/attempts/:attemptID", Handler: logHandlers.RetrieveAttempt, AuthMode: AuthTenant, TenantScoped: true}, + {Method: http.MethodPost, Path: "/:tenantID/destinations/:destinationID/attempts/:attemptID/retry", Handler: retryHandlers.RetryAttempt, AuthMode: AuthTenant, TenantScoped: true}, // Event routes - { - Method: http.MethodGet, - Path: "/:tenantID/events", - Handler: logHandlers.ListEvents, - AuthScope: AuthScopeAdminOrTenant, - Mode: RouteModeAlways, - Middlewares: []gin.HandlerFunc{ - RequireTenantMiddleware(tenantStore), - }, - }, - { - Method: http.MethodGet, - Path: "/:tenantID/events/:eventID", - Handler: logHandlers.RetrieveEvent, - AuthScope: AuthScopeAdminOrTenant, - Mode: RouteModeAlways, - Middlewares: []gin.HandlerFunc{ - RequireTenantMiddleware(tenantStore), - }, - }, + {Method: http.MethodGet, Path: "/:tenantID/events", Handler: logHandlers.ListEvents, AuthMode: AuthTenant, TenantScoped: true}, + {Method: http.MethodGet, Path: "/:tenantID/events/:eventID", Handler: logHandlers.RetrieveEvent, AuthMode: AuthTenant, TenantScoped: true}, // Attempt routes - { - Method: http.MethodGet, - Path: "/:tenantID/attempts", - Handler: logHandlers.ListAttempts, - AuthScope: AuthScopeAdminOrTenant, - Mode: RouteModeAlways, - Middlewares: []gin.HandlerFunc{ - RequireTenantMiddleware(tenantStore), - }, - }, - { - Method: http.MethodGet, - Path: "/:tenantID/attempts/:attemptID", - Handler: logHandlers.RetrieveAttempt, - AuthScope: AuthScopeAdminOrTenant, - Mode: RouteModeAlways, - Middlewares: []gin.HandlerFunc{ - RequireTenantMiddleware(tenantStore), - }, - }, - { - Method: http.MethodPost, - Path: "/:tenantID/attempts/:attemptID/retry", - Handler: retryHandlers.RetryAttempt, - AuthScope: AuthScopeAdminOrTenant, - Mode: RouteModeAlways, - Middlewares: []gin.HandlerFunc{ - RequireTenantMiddleware(tenantStore), - }, - }, + {Method: http.MethodGet, Path: "/:tenantID/attempts", Handler: logHandlers.ListAttempts, AuthMode: AuthTenant, TenantScoped: true}, + {Method: http.MethodGet, Path: "/:tenantID/attempts/:attemptID", Handler: logHandlers.RetrieveAttempt, AuthMode: AuthTenant, TenantScoped: true}, + {Method: http.MethodPost, Path: "/:tenantID/attempts/:attemptID/retry", Handler: retryHandlers.RetryAttempt, AuthMode: AuthTenant, TenantScoped: true}, } - // Register non-tenant routes at root - registerRoutes(apiRouter, cfg, nonTenantRoutes) + // Portal routes (conditionally appended) + portalRoutes := []RouteDefinition{ + {Method: http.MethodGet, Path: "/:tenantID/token", Handler: tenantHandlers.RetrieveToken, AuthMode: AuthAdmin, TenantScoped: true}, + {Method: http.MethodGet, Path: "/:tenantID/portal", Handler: tenantHandlers.RetrievePortal, AuthMode: AuthAdmin, TenantScoped: true}, + } - // Combine all tenant-scoped routes (routes with :tenantID in path) - tenantScopedRoutes := []RouteDefinition{} - tenantScopedRoutes = append(tenantScopedRoutes, tenantUpsertRoute) - tenantScopedRoutes = append(tenantScopedRoutes, portalRoutes...) - tenantScopedRoutes = append(tenantScopedRoutes, tenantAgnosticRoutes...) - tenantScopedRoutes = append(tenantScopedRoutes, tenantSpecificRoutes...) + if cfg.PortalEnabled() { + tenantRoutes = append(tenantRoutes, portalRoutes...) + } + + // Register non-tenant routes at root + registerRoutes(apiRouter, cfg, tenantStore, nonTenantRoutes) - // Register tenant-scoped routes under /tenants prefix + // Register tenant routes under /tenants prefix tenantsGroup := apiRouter.Group("/tenants") - registerRoutes(tenantsGroup, cfg, tenantScopedRoutes) + registerRoutes(tenantsGroup, cfg, tenantStore, tenantRoutes) // Register dev routes if gin.Mode() == gin.DebugMode { From 39c8e63e45dbcd0873700452a821b515e744a81d Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Fri, 30 Jan 2026 14:03:46 +0700 Subject: [PATCH 03/34] chore: update handlers to use mustTenantFromContext Co-Authored-By: Claude Opus 4.5 --- internal/apirouter/destination_handlers.go | 42 +++++++--------------- internal/apirouter/log_handlers.go | 15 -------- internal/apirouter/retry_handlers.go | 3 -- internal/apirouter/tenant_handlers.go | 16 ++------- 4 files changed, 14 insertions(+), 62 deletions(-) diff --git a/internal/apirouter/destination_handlers.go b/internal/apirouter/destination_handlers.go index d1ba5592..18ac9f56 100644 --- a/internal/apirouter/destination_handlers.go +++ b/internal/apirouter/destination_handlers.go @@ -45,12 +45,9 @@ func (h *DestinationHandlers) List(c *gin.Context) { }) } - tenantID := mustTenantIDFromContext(c) - if tenantID == "" { - return - } + tenant := mustTenantFromContext(c) - destinations, err := h.tenantStore.ListDestinationByTenant(c.Request.Context(), tenantID, opts) + destinations, err := h.tenantStore.ListDestinationByTenant(c.Request.Context(), tenant.ID, opts) if err != nil { AbortWithError(c, http.StatusInternalServerError, NewErrInternalServer(err)) return @@ -77,12 +74,9 @@ func (h *DestinationHandlers) Create(c *gin.Context) { return } - tenantID := mustTenantIDFromContext(c) - if tenantID == "" { - return - } + tenant := mustTenantFromContext(c) - destination := input.ToDestination(tenantID) + destination := input.ToDestination(tenant.ID) if err := destination.Validate(h.topics); err != nil { AbortWithValidationError(c, err) return @@ -112,11 +106,8 @@ func (h *DestinationHandlers) Create(c *gin.Context) { } func (h *DestinationHandlers) Retrieve(c *gin.Context) { - tenantID := mustTenantIDFromContext(c) - if tenantID == "" { - return - } - destination := h.mustRetrieveDestination(c, tenantID, c.Param("destinationID")) + tenant := mustTenantFromContext(c) + destination := h.mustRetrieveDestination(c, tenant.ID, c.Param("destinationID")) if destination == nil { return } @@ -138,11 +129,8 @@ func (h *DestinationHandlers) Update(c *gin.Context) { } // Retrieve destination. - tenantID := mustTenantIDFromContext(c) - if tenantID == "" { - return - } - originalDestination := h.mustRetrieveDestination(c, tenantID, c.Param("destinationID")) + tenant := mustTenantFromContext(c) + originalDestination := h.mustRetrieveDestination(c, tenant.ID, c.Param("destinationID")) if originalDestination == nil { return } @@ -211,11 +199,8 @@ func (h *DestinationHandlers) Update(c *gin.Context) { } func (h *DestinationHandlers) Delete(c *gin.Context) { - tenantID := mustTenantIDFromContext(c) - if tenantID == "" { - return - } - destination := h.mustRetrieveDestination(c, tenantID, c.Param("destinationID")) + tenant := mustTenantFromContext(c) + destination := h.mustRetrieveDestination(c, tenant.ID, c.Param("destinationID")) if destination == nil { return } @@ -256,11 +241,8 @@ func (h *DestinationHandlers) RetrieveProviderMetadata(c *gin.Context) { } func (h *DestinationHandlers) setDisabilityHandler(c *gin.Context, disabled bool) { - tenantID := mustTenantIDFromContext(c) - if tenantID == "" { - return - } - destination := h.mustRetrieveDestination(c, tenantID, c.Param("destinationID")) + tenant := mustTenantFromContext(c) + destination := h.mustRetrieveDestination(c, tenant.ID, c.Param("destinationID")) if destination == nil { return } diff --git a/internal/apirouter/log_handlers.go b/internal/apirouter/log_handlers.go index d3152000..0aed25f5 100644 --- a/internal/apirouter/log_handlers.go +++ b/internal/apirouter/log_handlers.go @@ -197,9 +197,6 @@ func toAPIAttempt(ar *logstore.AttemptRecord, opts IncludeOptions) APIAttempt { // Query params: event_id, destination_id, status, topic[], start, end, limit, next, prev, expand[], sort_order func (h *LogHandlers) ListAttempts(c *gin.Context) { tenant := mustTenantFromContext(c) - if tenant == nil { - return - } h.listAttemptsInternal(c, tenant.ID, "") } @@ -207,9 +204,6 @@ func (h *LogHandlers) ListAttempts(c *gin.Context) { // Same as ListAttempts but scoped to a specific destination via URL param. func (h *LogHandlers) ListDestinationAttempts(c *gin.Context) { tenant := mustTenantFromContext(c) - if tenant == nil { - return - } destinationID := c.Param("destinationID") h.listAttemptsInternal(c, tenant.ID, destinationID) } @@ -310,9 +304,6 @@ func (h *LogHandlers) listAttemptsInternal(c *gin.Context, tenantID string, dest // RetrieveEvent handles GET /:tenantID/events/:eventID func (h *LogHandlers) RetrieveEvent(c *gin.Context) { tenant := mustTenantFromContext(c) - if tenant == nil { - return - } eventID := c.Param("eventID") event, err := h.logStore.RetrieveEvent(c.Request.Context(), logstore.RetrieveEventRequest{ TenantID: tenant.ID, @@ -339,9 +330,6 @@ func (h *LogHandlers) RetrieveEvent(c *gin.Context) { // RetrieveAttempt handles GET /:tenantID/attempts/:attemptID func (h *LogHandlers) RetrieveAttempt(c *gin.Context) { tenant := mustTenantFromContext(c) - if tenant == nil { - return - } attemptID := c.Param("attemptID") attemptRecord, err := h.logStore.RetrieveAttempt(c.Request.Context(), logstore.RetrieveAttemptRequest{ @@ -378,9 +366,6 @@ func (h *LogHandlers) AdminListAttempts(c *gin.Context) { // Query params: destination_id, topic[], start, end, limit, next, prev, sort_order func (h *LogHandlers) ListEvents(c *gin.Context) { tenant := mustTenantFromContext(c) - if tenant == nil { - return - } h.listEventsInternal(c, tenant.ID) } diff --git a/internal/apirouter/retry_handlers.go b/internal/apirouter/retry_handlers.go index 8f5fbed3..487ee25d 100644 --- a/internal/apirouter/retry_handlers.go +++ b/internal/apirouter/retry_handlers.go @@ -39,9 +39,6 @@ func NewRetryHandlers( // - Destination must exist and be enabled func (h *RetryHandlers) RetryAttempt(c *gin.Context) { tenant := mustTenantFromContext(c) - if tenant == nil { - return - } attemptID := c.Param("attemptID") // 1. Look up attempt by ID diff --git a/internal/apirouter/tenant_handlers.go b/internal/apirouter/tenant_handlers.go index cbe00806..49f9599c 100644 --- a/internal/apirouter/tenant_handlers.go +++ b/internal/apirouter/tenant_handlers.go @@ -93,9 +93,6 @@ func (h *TenantHandlers) Upsert(c *gin.Context) { func (h *TenantHandlers) Retrieve(c *gin.Context) { tenant := mustTenantFromContext(c) - if tenant == nil { - return - } c.JSON(http.StatusOK, tenant) } @@ -162,12 +159,9 @@ func (h *TenantHandlers) List(c *gin.Context) { } func (h *TenantHandlers) Delete(c *gin.Context) { - tenantID := mustTenantIDFromContext(c) - if tenantID == "" { - return - } + tenant := mustTenantFromContext(c) - err := h.tenantStore.DeleteTenant(c.Request.Context(), tenantID) + err := h.tenantStore.DeleteTenant(c.Request.Context(), tenant.ID) if err != nil { if err == tenantstore.ErrTenantNotFound { c.Status(http.StatusNotFound) @@ -181,9 +175,6 @@ func (h *TenantHandlers) Delete(c *gin.Context) { func (h *TenantHandlers) RetrieveToken(c *gin.Context) { tenant := mustTenantFromContext(c) - if tenant == nil { - return - } jwtToken, err := JWT.New(h.jwtSecret, JWTClaims{ TenantID: tenant.ID, DeploymentID: h.deploymentID, @@ -197,9 +188,6 @@ func (h *TenantHandlers) RetrieveToken(c *gin.Context) { func (h *TenantHandlers) RetrievePortal(c *gin.Context) { tenant := mustTenantFromContext(c) - if tenant == nil { - return - } jwtToken, err := JWT.New(h.jwtSecret, JWTClaims{ TenantID: tenant.ID, DeploymentID: h.deploymentID, From b7f70f5e0770aa64eb26fd5e2b1c4573b54b6076 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Fri, 30 Jan 2026 15:04:30 +0700 Subject: [PATCH 04/34] =?UTF-8?q?chore:=20remove=20dead=20code=20=E2=80=94?= =?UTF-8?q?=20ErrTenantIDNotFound,=20SetTenantIDMiddleware,=20mustTenantID?= =?UTF-8?q?FromContext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove 3 dead symbols from auth_middleware.go - Remove TestSetTenantIDMiddleware from auth_middleware_test.go - RequireTenantMiddleware reads c.Param("tenantID") instead of c.Get - Remove SetTenantIDMiddleware() from router global middleware - Upsert handler uses c.Param("tenantID") directly Co-Authored-By: Claude Opus 4.5 --- internal/apirouter/auth_middleware.go | 23 ------ internal/apirouter/auth_middleware_test.go | 73 ------------------- .../apirouter/requiretenant_middleware.go | 6 +- internal/apirouter/router.go | 1 - internal/apirouter/tenant_handlers.go | 5 +- 5 files changed, 4 insertions(+), 104 deletions(-) diff --git a/internal/apirouter/auth_middleware.go b/internal/apirouter/auth_middleware.go index 52b96efc..e785bd45 100644 --- a/internal/apirouter/auth_middleware.go +++ b/internal/apirouter/auth_middleware.go @@ -11,7 +11,6 @@ import ( var ( ErrMissingAuthHeader = errors.New("missing authorization header") ErrInvalidBearerToken = errors.New("invalid bearer token format") - ErrTenantIDNotFound = errors.New("tenantID not found in context") ) const ( @@ -23,16 +22,6 @@ const ( RoleTenant = "tenant" ) -func SetTenantIDMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - tenantID := c.Param("tenantID") - if tenantID != "" { - c.Set("tenantID", tenantID) - } - c.Next() - } -} - // validateAuthHeader checks the Authorization header and returns the token if valid func validateAuthHeader(c *gin.Context) (string, error) { header := c.GetHeader("Authorization") @@ -149,15 +138,3 @@ func TenantJWTAuthMiddleware(apiKey string, jwtKey string) gin.HandlerFunc { } } -func mustTenantIDFromContext(c *gin.Context) string { - tenantID, exists := c.Get("tenantID") - if !exists { - c.AbortWithStatus(http.StatusInternalServerError) - return "" - } - if tenantID == nil { - c.AbortWithStatus(http.StatusInternalServerError) - return "" - } - return tenantID.(string) -} diff --git a/internal/apirouter/auth_middleware_test.go b/internal/apirouter/auth_middleware_test.go index 10a2871c..dee1005d 100644 --- a/internal/apirouter/auth_middleware_test.go +++ b/internal/apirouter/auth_middleware_test.go @@ -88,79 +88,6 @@ func TestPrivateAPIKeyRouter(t *testing.T) { }) } -func TestSetTenantIDMiddleware(t *testing.T) { - gin.SetMode(gin.TestMode) - t.Parallel() - - t.Run("should set tenantID from param", func(t *testing.T) { - t.Parallel() - - // Setup - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Params = []gin.Param{{Key: "tenantID", Value: "test_tenant"}} - - // Create a middleware chain - var tenantID string - handler := apirouter.SetTenantIDMiddleware() - nextHandler := func(c *gin.Context) { - val, exists := c.Get("tenantID") - if exists { - tenantID = val.(string) - } - } - - // Test - handler(c) - nextHandler(c) - - assert.Equal(t, "test_tenant", tenantID) - }) - - t.Run("should not set tenantID when param is empty", func(t *testing.T) { - t.Parallel() - - // Setup - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Params = []gin.Param{{Key: "tenantID", Value: ""}} - - // Create a middleware chain - var tenantIDExists bool - handler := apirouter.SetTenantIDMiddleware() - nextHandler := func(c *gin.Context) { - _, tenantIDExists = c.Get("tenantID") - } - - // Test - handler(c) - nextHandler(c) - - assert.False(t, tenantIDExists) - }) - - t.Run("should not set tenantID when param is missing", func(t *testing.T) { - t.Parallel() - - // Setup - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - - // Create a middleware chain - var tenantIDExists bool - handler := apirouter.SetTenantIDMiddleware() - nextHandler := func(c *gin.Context) { - _, tenantIDExists = c.Get("tenantID") - } - - // Test - handler(c) - nextHandler(c) - - assert.False(t, tenantIDExists) - }) -} - func TestAPIKeyOrTenantJWTAuthMiddleware(t *testing.T) { gin.SetMode(gin.TestMode) t.Parallel() diff --git a/internal/apirouter/requiretenant_middleware.go b/internal/apirouter/requiretenant_middleware.go index df080268..6ac6fa30 100644 --- a/internal/apirouter/requiretenant_middleware.go +++ b/internal/apirouter/requiretenant_middleware.go @@ -17,13 +17,13 @@ type TenantRetriever interface { func RequireTenantMiddleware(tenantRetriever TenantRetriever) gin.HandlerFunc { return func(c *gin.Context) { - tenantID, exists := c.Get("tenantID") - if !exists { + tenantID := c.Param("tenantID") + if tenantID == "" { c.AbortWithStatus(http.StatusNotFound) return } - tenant, err := tenantRetriever.RetrieveTenant(c.Request.Context(), tenantID.(string)) + tenant, err := tenantRetriever.RetrieveTenant(c.Request.Context(), tenantID) if err != nil { if err == tenantstore.ErrTenantDeleted { c.AbortWithStatus(http.StatusNotFound) diff --git a/internal/apirouter/router.go b/internal/apirouter/router.go index c593538f..1636f278 100644 --- a/internal/apirouter/router.go +++ b/internal/apirouter/router.go @@ -132,7 +132,6 @@ func NewRouter( portal.AddRoutes(r, cfg.PortalConfig) apiRouter := r.Group("/api/v1") - apiRouter.Use(SetTenantIDMiddleware()) tenantHandlers := NewTenantHandlers(logger, telemetry, cfg.JWTSecret, cfg.DeploymentID, tenantStore) destinationHandlers := NewDestinationHandlers(logger, telemetry, tenantStore, cfg.Topics, cfg.Registry) diff --git a/internal/apirouter/tenant_handlers.go b/internal/apirouter/tenant_handlers.go index 49f9599c..71977a0e 100644 --- a/internal/apirouter/tenant_handlers.go +++ b/internal/apirouter/tenant_handlers.go @@ -38,10 +38,7 @@ func NewTenantHandlers( } func (h *TenantHandlers) Upsert(c *gin.Context) { - tenantID := mustTenantIDFromContext(c) - if tenantID == "" { - return - } + tenantID := c.Param("tenantID") // Parse request body for metadata var input struct { From 8cf2d424c8da1b47974265ceaa11d453475f321e Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Fri, 30 Jan 2026 19:00:09 +0700 Subject: [PATCH 05/34] chore: gofmt --- internal/apirouter/auth_middleware.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/apirouter/auth_middleware.go b/internal/apirouter/auth_middleware.go index e785bd45..a01709c2 100644 --- a/internal/apirouter/auth_middleware.go +++ b/internal/apirouter/auth_middleware.go @@ -137,4 +137,3 @@ func TenantJWTAuthMiddleware(apiKey string, jwtKey string) gin.HandlerFunc { c.Next() } } - From 1dc2aa61a3ee4f9d50d7357f0cb296779aa12c1e Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sat, 31 Jan 2026 14:33:14 +0700 Subject: [PATCH 06/34] refactor: rename AuthTenant -> AuthAuthenticated --- internal/apirouter/router.go | 48 ++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/internal/apirouter/router.go b/internal/apirouter/router.go index 1636f278..a0a41d8a 100644 --- a/internal/apirouter/router.go +++ b/internal/apirouter/router.go @@ -24,9 +24,9 @@ import ( type AuthMode string const ( - AuthPublic AuthMode = "public" - AuthTenant AuthMode = "tenant" - AuthAdmin AuthMode = "admin" + AuthAdmin AuthMode = "admin" + AuthAuthenticated AuthMode = "authenticated" + AuthPublic AuthMode = "public" ) type RouteDefinition struct { @@ -68,7 +68,7 @@ func buildMiddlewareChain(cfg RouterConfig, tenantRetriever TenantRetriever, def switch def.AuthMode { case AuthAdmin: chain = append(chain, APIKeyAuthMiddleware(cfg.APIKey)) - case AuthTenant: + case AuthAuthenticated: chain = append(chain, APIKeyOrTenantJWTAuthMiddleware(cfg.APIKey, cfg.JWTSecret)) case AuthPublic: // no auth middleware @@ -152,36 +152,36 @@ func NewRouter( tenantRoutes := []RouteDefinition{ // Tenant CRUD {Method: http.MethodPut, Path: "/:tenantID", Handler: tenantHandlers.Upsert, AuthMode: AuthAdmin}, - {Method: http.MethodGet, Path: "/:tenantID", Handler: tenantHandlers.Retrieve, AuthMode: AuthTenant, TenantScoped: true}, - {Method: http.MethodDelete, Path: "/:tenantID", Handler: tenantHandlers.Delete, AuthMode: AuthTenant, TenantScoped: true}, + {Method: http.MethodGet, Path: "/:tenantID", Handler: tenantHandlers.Retrieve, AuthMode: AuthAuthenticated, TenantScoped: true}, + {Method: http.MethodDelete, Path: "/:tenantID", Handler: tenantHandlers.Delete, AuthMode: AuthAuthenticated, TenantScoped: true}, // Tenant-agnostic routes (no tenant lookup needed) - {Method: http.MethodGet, Path: "/:tenantID/destination-types", Handler: destinationHandlers.ListProviderMetadata, AuthMode: AuthTenant}, - {Method: http.MethodGet, Path: "/:tenantID/destination-types/:type", Handler: destinationHandlers.RetrieveProviderMetadata, AuthMode: AuthTenant}, - {Method: http.MethodGet, Path: "/:tenantID/topics", Handler: topicHandlers.List, AuthMode: AuthTenant}, + {Method: http.MethodGet, Path: "/:tenantID/destination-types", Handler: destinationHandlers.ListProviderMetadata, AuthMode: AuthAuthenticated}, + {Method: http.MethodGet, Path: "/:tenantID/destination-types/:type", Handler: destinationHandlers.RetrieveProviderMetadata, AuthMode: AuthAuthenticated}, + {Method: http.MethodGet, Path: "/:tenantID/topics", Handler: topicHandlers.List, AuthMode: AuthAuthenticated}, // Destination routes - {Method: http.MethodGet, Path: "/:tenantID/destinations", Handler: destinationHandlers.List, AuthMode: AuthTenant, TenantScoped: true}, - {Method: http.MethodPost, Path: "/:tenantID/destinations", Handler: destinationHandlers.Create, AuthMode: AuthTenant, TenantScoped: true}, - {Method: http.MethodGet, Path: "/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Retrieve, AuthMode: AuthTenant, TenantScoped: true}, - {Method: http.MethodPatch, Path: "/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Update, AuthMode: AuthTenant, TenantScoped: true}, - {Method: http.MethodDelete, Path: "/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Delete, AuthMode: AuthTenant, TenantScoped: true}, - {Method: http.MethodPut, Path: "/:tenantID/destinations/:destinationID/enable", Handler: destinationHandlers.Enable, AuthMode: AuthTenant, TenantScoped: true}, - {Method: http.MethodPut, Path: "/:tenantID/destinations/:destinationID/disable", Handler: destinationHandlers.Disable, AuthMode: AuthTenant, TenantScoped: true}, + {Method: http.MethodGet, Path: "/:tenantID/destinations", Handler: destinationHandlers.List, AuthMode: AuthAuthenticated, TenantScoped: true}, + {Method: http.MethodPost, Path: "/:tenantID/destinations", Handler: destinationHandlers.Create, AuthMode: AuthAuthenticated, TenantScoped: true}, + {Method: http.MethodGet, Path: "/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Retrieve, AuthMode: AuthAuthenticated, TenantScoped: true}, + {Method: http.MethodPatch, Path: "/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Update, AuthMode: AuthAuthenticated, TenantScoped: true}, + {Method: http.MethodDelete, Path: "/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Delete, AuthMode: AuthAuthenticated, TenantScoped: true}, + {Method: http.MethodPut, Path: "/:tenantID/destinations/:destinationID/enable", Handler: destinationHandlers.Enable, AuthMode: AuthAuthenticated, TenantScoped: true}, + {Method: http.MethodPut, Path: "/:tenantID/destinations/:destinationID/disable", Handler: destinationHandlers.Disable, AuthMode: AuthAuthenticated, TenantScoped: true}, // Destination-scoped attempt routes - {Method: http.MethodGet, Path: "/:tenantID/destinations/:destinationID/attempts", Handler: logHandlers.ListDestinationAttempts, AuthMode: AuthTenant, TenantScoped: true}, - {Method: http.MethodGet, Path: "/:tenantID/destinations/:destinationID/attempts/:attemptID", Handler: logHandlers.RetrieveAttempt, AuthMode: AuthTenant, TenantScoped: true}, - {Method: http.MethodPost, Path: "/:tenantID/destinations/:destinationID/attempts/:attemptID/retry", Handler: retryHandlers.RetryAttempt, AuthMode: AuthTenant, TenantScoped: true}, + {Method: http.MethodGet, Path: "/:tenantID/destinations/:destinationID/attempts", Handler: logHandlers.ListDestinationAttempts, AuthMode: AuthAuthenticated, TenantScoped: true}, + {Method: http.MethodGet, Path: "/:tenantID/destinations/:destinationID/attempts/:attemptID", Handler: logHandlers.RetrieveAttempt, AuthMode: AuthAuthenticated, TenantScoped: true}, + {Method: http.MethodPost, Path: "/:tenantID/destinations/:destinationID/attempts/:attemptID/retry", Handler: retryHandlers.RetryAttempt, AuthMode: AuthAuthenticated, TenantScoped: true}, // Event routes - {Method: http.MethodGet, Path: "/:tenantID/events", Handler: logHandlers.ListEvents, AuthMode: AuthTenant, TenantScoped: true}, - {Method: http.MethodGet, Path: "/:tenantID/events/:eventID", Handler: logHandlers.RetrieveEvent, AuthMode: AuthTenant, TenantScoped: true}, + {Method: http.MethodGet, Path: "/:tenantID/events", Handler: logHandlers.ListEvents, AuthMode: AuthAuthenticated, TenantScoped: true}, + {Method: http.MethodGet, Path: "/:tenantID/events/:eventID", Handler: logHandlers.RetrieveEvent, AuthMode: AuthAuthenticated, TenantScoped: true}, // Attempt routes - {Method: http.MethodGet, Path: "/:tenantID/attempts", Handler: logHandlers.ListAttempts, AuthMode: AuthTenant, TenantScoped: true}, - {Method: http.MethodGet, Path: "/:tenantID/attempts/:attemptID", Handler: logHandlers.RetrieveAttempt, AuthMode: AuthTenant, TenantScoped: true}, - {Method: http.MethodPost, Path: "/:tenantID/attempts/:attemptID/retry", Handler: retryHandlers.RetryAttempt, AuthMode: AuthTenant, TenantScoped: true}, + {Method: http.MethodGet, Path: "/:tenantID/attempts", Handler: logHandlers.ListAttempts, AuthMode: AuthAuthenticated, TenantScoped: true}, + {Method: http.MethodGet, Path: "/:tenantID/attempts/:attemptID", Handler: logHandlers.RetrieveAttempt, AuthMode: AuthAuthenticated, TenantScoped: true}, + {Method: http.MethodPost, Path: "/:tenantID/attempts/:attemptID/retry", Handler: retryHandlers.RetryAttempt, AuthMode: AuthAuthenticated, TenantScoped: true}, } // Portal routes (conditionally appended) From 8bd9d64e8e111d31d55b6541acd9b536bdcfd835 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sat, 31 Jan 2026 14:42:12 +0700 Subject: [PATCH 07/34] chore: rename AuthenticatedMiddleware --- internal/apirouter/auth_middleware.go | 2 +- internal/apirouter/auth_middleware_test.go | 18 +++++++++--------- internal/apirouter/router.go | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/internal/apirouter/auth_middleware.go b/internal/apirouter/auth_middleware.go index a01709c2..6fe8a7bb 100644 --- a/internal/apirouter/auth_middleware.go +++ b/internal/apirouter/auth_middleware.go @@ -64,7 +64,7 @@ func APIKeyAuthMiddleware(apiKey string) gin.HandlerFunc { } } -func APIKeyOrTenantJWTAuthMiddleware(apiKey string, jwtKey string) gin.HandlerFunc { +func AuthenticatedMiddleware(apiKey string, jwtKey string) gin.HandlerFunc { // When apiKey is empty, everything is admin-only through VPC if apiKey == "" { return func(c *gin.Context) { diff --git a/internal/apirouter/auth_middleware_test.go b/internal/apirouter/auth_middleware_test.go index dee1005d..a77221a8 100644 --- a/internal/apirouter/auth_middleware_test.go +++ b/internal/apirouter/auth_middleware_test.go @@ -88,7 +88,7 @@ func TestPrivateAPIKeyRouter(t *testing.T) { }) } -func TestAPIKeyOrTenantJWTAuthMiddleware(t *testing.T) { +func TestAuthenticatedMiddleware(t *testing.T) { gin.SetMode(gin.TestMode) t.Parallel() @@ -115,7 +115,7 @@ func TestAPIKeyOrTenantJWTAuthMiddleware(t *testing.T) { c.Request.Header.Set("Authorization", "Bearer "+token) // Test - handler := apirouter.APIKeyOrTenantJWTAuthMiddleware(apiKey, jwtSecret) + handler := apirouter.AuthenticatedMiddleware(apiKey, jwtSecret) handler(c) assert.Equal(t, http.StatusUnauthorized, c.Writer.Status()) @@ -141,7 +141,7 @@ func TestAPIKeyOrTenantJWTAuthMiddleware(t *testing.T) { // Create a middleware chain var contextTenantID string - handler := apirouter.APIKeyOrTenantJWTAuthMiddleware(apiKey, jwtSecret) + handler := apirouter.AuthenticatedMiddleware(apiKey, jwtSecret) nextHandler := func(c *gin.Context) { val, exists := c.Get("tenantID") if exists { @@ -174,7 +174,7 @@ func TestAPIKeyOrTenantJWTAuthMiddleware(t *testing.T) { c.Request.Header.Set("Authorization", "Bearer "+token) // Test - handler := apirouter.APIKeyOrTenantJWTAuthMiddleware(apiKey, jwtSecret) + handler := apirouter.AuthenticatedMiddleware(apiKey, jwtSecret) handler(c) assert.Equal(t, http.StatusUnauthorized, c.Writer.Status()) @@ -193,7 +193,7 @@ func TestAPIKeyOrTenantJWTAuthMiddleware(t *testing.T) { c.Request.Header.Set("Authorization", "Bearer "+apiKey) // Test - handler := apirouter.APIKeyOrTenantJWTAuthMiddleware(apiKey, jwtSecret) + handler := apirouter.AuthenticatedMiddleware(apiKey, jwtSecret) handler(c) assert.NotEqual(t, http.StatusUnauthorized, c.Writer.Status()) @@ -373,13 +373,13 @@ func TestAuthRole(t *testing.T) { }) }) - t.Run("APIKeyOrTenantJWTAuthMiddleware", func(t *testing.T) { + t.Run("AuthenticatedMiddleware", func(t *testing.T) { t.Run("should set RoleAdmin when apiKey is empty", func(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodGet, "/", nil) - handler := apirouter.APIKeyOrTenantJWTAuthMiddleware("", "jwt_secret") + handler := apirouter.AuthenticatedMiddleware("", "jwt_secret") var role string nextHandler := func(c *gin.Context) { val, exists := c.Get("authRole") @@ -400,7 +400,7 @@ func TestAuthRole(t *testing.T) { c.Request = httptest.NewRequest(http.MethodGet, "/", nil) c.Request.Header.Set("Authorization", "Bearer key") - handler := apirouter.APIKeyOrTenantJWTAuthMiddleware("key", "jwt_secret") + handler := apirouter.AuthenticatedMiddleware("key", "jwt_secret") var role string nextHandler := func(c *gin.Context) { val, exists := c.Get("authRole") @@ -422,7 +422,7 @@ func TestAuthRole(t *testing.T) { token := newJWTToken(t, "jwt_secret", "tenant-id") c.Request.Header.Set("Authorization", "Bearer "+token) - handler := apirouter.APIKeyOrTenantJWTAuthMiddleware("key", "jwt_secret") + handler := apirouter.AuthenticatedMiddleware("key", "jwt_secret") var role string nextHandler := func(c *gin.Context) { val, exists := c.Get("authRole") diff --git a/internal/apirouter/router.go b/internal/apirouter/router.go index a0a41d8a..6e679049 100644 --- a/internal/apirouter/router.go +++ b/internal/apirouter/router.go @@ -69,7 +69,7 @@ func buildMiddlewareChain(cfg RouterConfig, tenantRetriever TenantRetriever, def case AuthAdmin: chain = append(chain, APIKeyAuthMiddleware(cfg.APIKey)) case AuthAuthenticated: - chain = append(chain, APIKeyOrTenantJWTAuthMiddleware(cfg.APIKey, cfg.JWTSecret)) + chain = append(chain, AuthenticatedMiddleware(cfg.APIKey, cfg.JWTSecret)) case AuthPublic: // no auth middleware } From f8a884aa639df1c8b95680971dc995adb9660bb7 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sat, 31 Jan 2026 19:25:26 +0700 Subject: [PATCH 08/34] refactor: simplify auth context between apikey & jwt --- internal/apirouter/auth_middleware.go | 110 +++++++++++++++- internal/apirouter/auth_middleware_test.go | 47 ++++++- internal/apirouter/log_handlers.go | 56 ++++---- internal/apirouter/log_handlers_test.go | 52 ++++---- .../apirouter/requiretenant_middleware.go | 50 ------- .../requiretenant_middleware_test.go | 44 ------- internal/apirouter/retry_handlers.go | 62 ++++++--- internal/apirouter/retry_handlers_test.go | 98 ++++++++++++-- internal/apirouter/router.go | 123 +++++++----------- internal/apirouter/router_test.go | 7 +- internal/apirouter/tenant_handlers.go | 9 ++ 11 files changed, 399 insertions(+), 259 deletions(-) delete mode 100644 internal/apirouter/requiretenant_middleware.go delete mode 100644 internal/apirouter/requiretenant_middleware_test.go diff --git a/internal/apirouter/auth_middleware.go b/internal/apirouter/auth_middleware.go index 6fe8a7bb..8f21c9b1 100644 --- a/internal/apirouter/auth_middleware.go +++ b/internal/apirouter/auth_middleware.go @@ -1,11 +1,14 @@ package apirouter import ( + "context" "errors" "net/http" "strings" "github.com/gin-gonic/gin" + "github.com/hookdeck/outpost/internal/models" + "github.com/hookdeck/outpost/internal/tenantstore" ) var ( @@ -22,6 +25,12 @@ const ( RoleTenant = "tenant" ) +// TenantRetriever is satisfied by tenantstore.TenantStore. +// Defined here to avoid coupling the router/middleware to the full store interface. +type TenantRetriever interface { + RetrieveTenant(ctx context.Context, tenantID string) (*models.Tenant, error) +} + // validateAuthHeader checks the Authorization header and returns the token if valid func validateAuthHeader(c *gin.Context) (string, error) { header := c.GetHeader("Authorization") @@ -38,7 +47,7 @@ func validateAuthHeader(c *gin.Context) (string, error) { return token, nil } -func APIKeyAuthMiddleware(apiKey string) gin.HandlerFunc { +func AdminMiddleware(apiKey string) gin.HandlerFunc { // When apiKey is empty, everything is admin-only through VPC if apiKey == "" { return func(c *gin.Context) { @@ -106,6 +115,105 @@ func AuthenticatedMiddleware(apiKey string, jwtKey string) gin.HandlerFunc { } } +// resolveTenantMiddleware resolves the tenant from the DB and sets it in context. +// For JWT auth (tenantID already in context), it resolves using that ID. +// For API key auth on tenant-scoped routes, it resolves using the :tenantID URL param. +// When requireTenant is true, missing/deleted tenant returns an error (404 for admin, 401 for JWT). +// When requireTenant is false, it only resolves if JWT set a tenantID in context. +func resolveTenantMiddleware(tenantRetriever TenantRetriever, requireTenant bool) gin.HandlerFunc { + return func(c *gin.Context) { + _, isJWT := c.Get("tenantID") + + if !requireTenant && !isJWT { + c.Next() + return + } + + tenantID := tenantIDFromContext(c) + if tenantID == "" { + if requireTenant { + AbortWithError(c, http.StatusNotFound, NewErrNotFound("tenant")) + } else { + c.Next() + } + return + } + + tenant, err := tenantRetriever.RetrieveTenant(c.Request.Context(), tenantID) + if err != nil { + if err == tenantstore.ErrTenantDeleted { + if isJWT { + c.AbortWithStatus(http.StatusUnauthorized) + } else { + AbortWithError(c, http.StatusNotFound, NewErrNotFound("tenant")) + } + return + } + AbortWithError(c, http.StatusInternalServerError, NewErrInternalServer(err)) + return + } + if tenant == nil { + if isJWT { + c.AbortWithStatus(http.StatusUnauthorized) + } else { + AbortWithError(c, http.StatusNotFound, NewErrNotFound("tenant")) + } + return + } + + c.Set("tenant", tenant) + c.Next() + } +} + +// tenantIDFromContext returns the tenant ID from context (set by JWT middleware) or +// falls back to the :tenantID URL param (for API key auth on tenant-scoped routes). +// Returns empty string when using API key auth on a route with no :tenantID in path. +func tenantIDFromContext(c *gin.Context) string { + if id, ok := c.Get("tenantID"); ok { + return id.(string) + } + return c.Param("tenantID") +} + +// resolveTenantIDFilter returns the effective tenant ID for log queries. +// If JWT set tenantID in context and a tenant_id query param is also provided, +// they must match — otherwise abort with 403. +func resolveTenantIDFilter(c *gin.Context) (string, bool) { + ctxTenantID := tenantIDFromContext(c) + queryTenantID := c.Query("tenant_id") + if ctxTenantID != "" && queryTenantID != "" && ctxTenantID != queryTenantID { + AbortWithError(c, http.StatusForbidden, ErrorResponse{ + Code: http.StatusForbidden, + Message: "tenant_id query parameter does not match authenticated tenant", + }) + return "", false + } + if ctxTenantID != "" { + return ctxTenantID, true + } + return queryTenantID, true +} + +// tenantFromContext returns the resolved tenant from context, if present. +// Returns nil when the request is not JWT-authenticated or the route doesn't require a tenant. +func tenantFromContext(c *gin.Context) *models.Tenant { + if t, ok := c.Get("tenant"); ok { + return t.(*models.Tenant) + } + return nil +} + +// mustTenantFromContext returns the resolved tenant from context, panicking if absent. +// Only use on routes where RequireTenant is true. +func mustTenantFromContext(c *gin.Context) *models.Tenant { + tenant, ok := c.Get("tenant") + if !ok { + panic("mustTenantFromContext: tenant not found in context - route is likely missing RequireTenant") + } + return tenant.(*models.Tenant) +} + func TenantJWTAuthMiddleware(apiKey string, jwtKey string) gin.HandlerFunc { return func(c *gin.Context) { // When apiKey or jwtKey is empty, JWT-only routes should not exist diff --git a/internal/apirouter/auth_middleware_test.go b/internal/apirouter/auth_middleware_test.go index a77221a8..b6fd6390 100644 --- a/internal/apirouter/auth_middleware_test.go +++ b/internal/apirouter/auth_middleware_test.go @@ -1,6 +1,7 @@ package apirouter_test import ( + "context" "net/http" "net/http/httptest" "testing" @@ -9,8 +10,11 @@ import ( "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/hookdeck/outpost/internal/apirouter" + "github.com/hookdeck/outpost/internal/idgen" + "github.com/hookdeck/outpost/internal/models" ) func TestPublicRouter(t *testing.T) { @@ -22,7 +26,7 @@ func TestPublicRouter(t *testing.T) { t.Run("should accept requests without a token", func(t *testing.T) { t.Parallel() w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/tenant-id/topics", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/topics", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) }) @@ -30,7 +34,7 @@ func TestPublicRouter(t *testing.T) { t.Run("should accept requests with an invalid authorization token", func(t *testing.T) { t.Parallel() w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/tenant-id/topics", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/topics", nil) req.Header.Set("Authorization", "invalid key") router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -39,7 +43,7 @@ func TestPublicRouter(t *testing.T) { t.Run("should accept requests with a valid authorization token", func(t *testing.T) { t.Parallel() w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/tenant-id/topics", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/topics", nil) req.Header.Set("Authorization", "Bearer key") router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -223,6 +227,37 @@ func newExpiredJWTToken(t *testing.T, secret string, tenantID string) string { return token } +func TestResolveTenantMiddleware(t *testing.T) { + t.Parallel() + + const apiKey = "" + router, _, redisClient := setupTestRouter(t, apiKey, "") + + t.Run("should reject requests without a tenant", func(t *testing.T) { + t.Parallel() + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/invalid_tenant_id/destinations", nil) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusNotFound, w.Code) + }) + + t.Run("should allow requests with a valid tenant", func(t *testing.T) { + t.Parallel() + + tenant := models.Tenant{ + ID: idgen.String(), + } + tenantStore := setupTestTenantStore(t, redisClient) + err := tenantStore.UpsertTenant(context.Background(), tenant) + require.Nil(t, err) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenant.ID+"/destinations", nil) + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + }) +} + func TestTenantJWTAuthMiddleware(t *testing.T) { gin.SetMode(gin.TestMode) t.Parallel() @@ -330,13 +365,13 @@ func TestAuthRole(t *testing.T) { gin.SetMode(gin.TestMode) t.Parallel() - t.Run("APIKeyAuthMiddleware", func(t *testing.T) { + t.Run("AdminMiddleware", func(t *testing.T) { t.Run("should set RoleAdmin when apiKey is empty", func(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodGet, "/", nil) - handler := apirouter.APIKeyAuthMiddleware("") + handler := apirouter.AdminMiddleware("") var role string nextHandler := func(c *gin.Context) { val, exists := c.Get("authRole") @@ -357,7 +392,7 @@ func TestAuthRole(t *testing.T) { c.Request = httptest.NewRequest(http.MethodGet, "/", nil) c.Request.Header.Set("Authorization", "Bearer key") - handler := apirouter.APIKeyAuthMiddleware("key") + handler := apirouter.AdminMiddleware("key") var role string nextHandler := func(c *gin.Context) { val, exists := c.Get("authRole") diff --git a/internal/apirouter/log_handlers.go b/internal/apirouter/log_handlers.go index 0aed25f5..43e4f63c 100644 --- a/internal/apirouter/log_handlers.go +++ b/internal/apirouter/log_handlers.go @@ -193,11 +193,15 @@ func toAPIAttempt(ar *logstore.AttemptRecord, opts IncludeOptions) APIAttempt { return api } -// ListAttempts handles GET /:tenantID/attempts -// Query params: event_id, destination_id, status, topic[], start, end, limit, next, prev, expand[], sort_order +// ListAttempts handles GET /attempts +// Query params: tenant_id, event_id, destination_id, status, topic[], start, end, limit, next, prev, expand[], sort_order func (h *LogHandlers) ListAttempts(c *gin.Context) { - tenant := mustTenantFromContext(c) - h.listAttemptsInternal(c, tenant.ID, "") + // Authz: JWT users can only query their own tenant's attempts + tenantID, ok := resolveTenantIDFilter(c) + if !ok { + return + } + h.listAttemptsInternal(c, tenantID, "") } // ListDestinationAttempts handles GET /:tenantID/destinations/:destinationID/attempts @@ -301,12 +305,16 @@ func (h *LogHandlers) listAttemptsInternal(c *gin.Context, tenantID string, dest }) } -// RetrieveEvent handles GET /:tenantID/events/:eventID +// RetrieveEvent handles GET /events/:eventID func (h *LogHandlers) RetrieveEvent(c *gin.Context) { - tenant := mustTenantFromContext(c) + // Authz: JWT users can only query their own tenant's events + tenantID, ok := resolveTenantIDFilter(c) + if !ok { + return + } eventID := c.Param("eventID") event, err := h.logStore.RetrieveEvent(c.Request.Context(), logstore.RetrieveEventRequest{ - TenantID: tenant.ID, + TenantID: tenantID, EventID: eventID, }) if err != nil { @@ -327,13 +335,17 @@ func (h *LogHandlers) RetrieveEvent(c *gin.Context) { }) } -// RetrieveAttempt handles GET /:tenantID/attempts/:attemptID +// RetrieveAttempt handles GET /attempts/:attemptID func (h *LogHandlers) RetrieveAttempt(c *gin.Context) { - tenant := mustTenantFromContext(c) + // Authz: JWT users can only query their own tenant's attempts + tenantID, ok := resolveTenantIDFilter(c) + if !ok { + return + } attemptID := c.Param("attemptID") attemptRecord, err := h.logStore.RetrieveAttempt(c.Request.Context(), logstore.RetrieveAttemptRequest{ - TenantID: tenant.ID, + TenantID: tenantID, AttemptID: attemptID, }) if err != nil { @@ -350,23 +362,15 @@ func (h *LogHandlers) RetrieveAttempt(c *gin.Context) { c.JSON(http.StatusOK, toAPIAttempt(attemptRecord, includeOpts)) } -// AdminListEvents handles GET /events (admin-only, cross-tenant) -// Query params: tenant_id (optional), destination_id, topic[], start, end, limit, next, prev, sort_order -func (h *LogHandlers) AdminListEvents(c *gin.Context) { - h.listEventsInternal(c, c.Query("tenant_id")) -} - -// AdminListAttempts handles GET /attempts (admin-only, cross-tenant) -// Query params: tenant_id (optional), event_id, destination_id, status, topic[], start, end, limit, next, prev, expand[], sort_order -func (h *LogHandlers) AdminListAttempts(c *gin.Context) { - h.listAttemptsInternal(c, c.Query("tenant_id"), "") -} - -// ListEvents handles GET /:tenantID/events -// Query params: destination_id, topic[], start, end, limit, next, prev, sort_order +// ListEvents handles GET /events +// Query params: tenant_id, destination_id, topic[], start, end, limit, next, prev, sort_order func (h *LogHandlers) ListEvents(c *gin.Context) { - tenant := mustTenantFromContext(c) - h.listEventsInternal(c, tenant.ID) + // Authz: JWT users can only query their own tenant's events + tenantID, ok := resolveTenantIDFilter(c) + if !ok { + return + } + h.listEventsInternal(c, tenantID) } func (h *LogHandlers) listEventsInternal(c *gin.Context, tenantID string) { diff --git a/internal/apirouter/log_handlers_test.go b/internal/apirouter/log_handlers_test.go index a4c56517..1956f6d5 100644 --- a/internal/apirouter/log_handlers_test.go +++ b/internal/apirouter/log_handlers_test.go @@ -37,7 +37,7 @@ func TestListAttempts(t *testing.T) { t.Run("should return empty list when no attempts", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/attempts", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID, nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -75,7 +75,7 @@ func TestListAttempts(t *testing.T) { require.NoError(t, result.logStore.InsertMany(context.Background(), []*models.LogEntry{{Event: event, Attempt: attempt}})) w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/attempts", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID, nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -95,7 +95,7 @@ func TestListAttempts(t *testing.T) { t.Run("should include event when include=event", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/attempts?include=event", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID+"&include=event", nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -116,7 +116,7 @@ func TestListAttempts(t *testing.T) { t.Run("should include event.data when include=event.data", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/attempts?include=event.data", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID+"&include=event.data", nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -135,7 +135,7 @@ func TestListAttempts(t *testing.T) { t.Run("should filter by destination_id", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/attempts?destination_id="+destinationID, nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID+"&destination_id="+destinationID, nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -149,7 +149,7 @@ func TestListAttempts(t *testing.T) { t.Run("should filter by non-existent destination_id", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/attempts?destination_id=nonexistent", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID+"&destination_id=nonexistent", nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -171,7 +171,7 @@ func TestListAttempts(t *testing.T) { t.Run("should exclude response_data by default", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/attempts", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID, nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -216,7 +216,7 @@ func TestListAttempts(t *testing.T) { require.NoError(t, result.logStore.InsertMany(context.Background(), []*models.LogEntry{{Event: event, Attempt: attempt}})) w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/attempts?include=response_data", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID+"&include=response_data", nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -243,7 +243,7 @@ func TestListAttempts(t *testing.T) { t.Run("should support comma-separated include param", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/attempts?include=event,response_data", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID+"&include=event,response_data", nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -263,7 +263,7 @@ func TestListAttempts(t *testing.T) { t.Run("should return validation error for invalid dir", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/attempts?dir=invalid", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID+"&dir=invalid", nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusUnprocessableEntity, w.Code) @@ -271,7 +271,7 @@ func TestListAttempts(t *testing.T) { t.Run("should accept valid dir param", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/attempts?dir=asc", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID+"&dir=asc", nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -279,7 +279,7 @@ func TestListAttempts(t *testing.T) { t.Run("should cap limit at 1000", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/attempts?limit=5000", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID+"&limit=5000", nil) result.router.ServeHTTP(w, req) // Should succeed, limit is silently capped @@ -333,7 +333,7 @@ func TestRetrieveAttempt(t *testing.T) { t.Run("should retrieve attempt by ID", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/attempts/"+attemptID, nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/attempts/"+attemptID+"?tenant_id="+tenantID, nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -349,7 +349,7 @@ func TestRetrieveAttempt(t *testing.T) { t.Run("should include event when include=event", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/attempts/"+attemptID+"?include=event", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/attempts/"+attemptID+"?tenant_id="+tenantID+"&include=event", nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -366,7 +366,7 @@ func TestRetrieveAttempt(t *testing.T) { t.Run("should include event.data when include=event.data", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/attempts/"+attemptID+"?include=event.data", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/attempts/"+attemptID+"?tenant_id="+tenantID+"&include=event.data", nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -448,7 +448,7 @@ func TestRetrieveEvent(t *testing.T) { t.Run("should retrieve event by ID", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/events/"+eventID, nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/events/"+eventID+"?tenant_id="+tenantID, nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -503,7 +503,7 @@ func TestListEvents(t *testing.T) { t.Run("should return empty list when no events", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/events", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/events?tenant_id="+tenantID, nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -544,7 +544,7 @@ func TestListEvents(t *testing.T) { require.NoError(t, result.logStore.InsertMany(context.Background(), []*models.LogEntry{{Event: event, Attempt: attempt}})) w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/events", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/events?tenant_id="+tenantID, nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -563,7 +563,7 @@ func TestListEvents(t *testing.T) { t.Run("should filter by destination_id", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/events?destination_id="+destinationID, nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/events?tenant_id="+tenantID+"&destination_id="+destinationID, nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -577,7 +577,7 @@ func TestListEvents(t *testing.T) { t.Run("should filter by non-existent destination_id", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/events?destination_id=nonexistent", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/events?tenant_id="+tenantID+"&destination_id=nonexistent", nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -591,7 +591,7 @@ func TestListEvents(t *testing.T) { t.Run("should filter by topic", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/events?topic=user.created", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/events?tenant_id="+tenantID+"&topic=user.created", nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -617,7 +617,7 @@ func TestListEvents(t *testing.T) { t.Run("should return validation error for invalid time filter", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/events?time[gte]=invalid", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/events?tenant_id="+tenantID+"&time[gte]=invalid", nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusUnprocessableEntity, w.Code) @@ -625,7 +625,7 @@ func TestListEvents(t *testing.T) { t.Run("should return validation error for invalid time lte filter", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/events?time[lte]=invalid", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/events?tenant_id="+tenantID+"&time[lte]=invalid", nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusUnprocessableEntity, w.Code) @@ -633,7 +633,7 @@ func TestListEvents(t *testing.T) { t.Run("should return validation error for invalid dir", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/events?dir=invalid", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/events?tenant_id="+tenantID+"&dir=invalid", nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusUnprocessableEntity, w.Code) @@ -641,7 +641,7 @@ func TestListEvents(t *testing.T) { t.Run("should accept valid dir param", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/events?dir=asc", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/events?tenant_id="+tenantID+"&dir=asc", nil) result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) @@ -649,7 +649,7 @@ func TestListEvents(t *testing.T) { t.Run("should cap limit at 1000", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/events?limit=5000", nil) + req, _ := http.NewRequest("GET", baseAPIPath+"/events?tenant_id="+tenantID+"&limit=5000", nil) result.router.ServeHTTP(w, req) // Should succeed, limit is silently capped diff --git a/internal/apirouter/requiretenant_middleware.go b/internal/apirouter/requiretenant_middleware.go deleted file mode 100644 index 6ac6fa30..00000000 --- a/internal/apirouter/requiretenant_middleware.go +++ /dev/null @@ -1,50 +0,0 @@ -package apirouter - -import ( - "context" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/hookdeck/outpost/internal/models" - "github.com/hookdeck/outpost/internal/tenantstore" -) - -// TenantRetriever is satisfied by tenantstore.TenantStore. -// Defined here to avoid coupling the router/middleware to the full store interface. -type TenantRetriever interface { - RetrieveTenant(ctx context.Context, tenantID string) (*models.Tenant, error) -} - -func RequireTenantMiddleware(tenantRetriever TenantRetriever) gin.HandlerFunc { - return func(c *gin.Context) { - tenantID := c.Param("tenantID") - if tenantID == "" { - c.AbortWithStatus(http.StatusNotFound) - return - } - - tenant, err := tenantRetriever.RetrieveTenant(c.Request.Context(), tenantID) - if err != nil { - if err == tenantstore.ErrTenantDeleted { - c.AbortWithStatus(http.StatusNotFound) - return - } - AbortWithError(c, http.StatusInternalServerError, NewErrInternalServer(err)) - return - } - if tenant == nil { - c.AbortWithStatus(http.StatusNotFound) - return - } - c.Set("tenant", tenant) - c.Next() - } -} - -func mustTenantFromContext(c *gin.Context) *models.Tenant { - tenant, ok := c.Get("tenant") - if !ok { - panic("mustTenantFromContext: tenant not found in context - route is likely missing TenantScoped: true") - } - return tenant.(*models.Tenant) -} diff --git a/internal/apirouter/requiretenant_middleware_test.go b/internal/apirouter/requiretenant_middleware_test.go deleted file mode 100644 index 6d639262..00000000 --- a/internal/apirouter/requiretenant_middleware_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package apirouter_test - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - - "github.com/hookdeck/outpost/internal/idgen" - "github.com/hookdeck/outpost/internal/models" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRequireTenantMiddleware(t *testing.T) { - t.Parallel() - - const apiKey = "" - router, _, redisClient := setupTestRouter(t, apiKey, "") - - t.Run("should reject requests without a tenant", func(t *testing.T) { - t.Parallel() - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/invalid_tenant_id/destinations", nil) - router.ServeHTTP(w, req) - assert.Equal(t, http.StatusNotFound, w.Code) - }) - - t.Run("should allow requests with a valid tenant", func(t *testing.T) { - t.Parallel() - - tenant := models.Tenant{ - ID: idgen.String(), - } - tenantStore := setupTestTenantStore(t, redisClient) - err := tenantStore.UpsertTenant(context.Background(), tenant) - require.Nil(t, err) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenant.ID+"/destinations", nil) - router.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - }) -} diff --git a/internal/apirouter/retry_handlers.go b/internal/apirouter/retry_handlers.go index 487ee25d..c5789e33 100644 --- a/internal/apirouter/retry_handlers.go +++ b/internal/apirouter/retry_handlers.go @@ -33,30 +33,47 @@ func NewRetryHandlers( } } -// RetryAttempt handles POST /:tenantID/attempts/:attemptID/retry -// Constraints: -// - Only the latest attempt for an event+destination pair can be retried -// - Destination must exist and be enabled -func (h *RetryHandlers) RetryAttempt(c *gin.Context) { - tenant := mustTenantFromContext(c) - attemptID := c.Param("attemptID") +type retryRequest struct { + EventID string `json:"event_id" binding:"required"` + DestinationID string `json:"destination_id" binding:"required"` +} + +// Retry handles POST /retry +// Accepts { event_id, destination_id } in body. +// Looks up the event, verifies the destination exists and is enabled, then publishes a manual delivery task. +func (h *RetryHandlers) Retry(c *gin.Context) { + var req retryRequest + if err := c.ShouldBindJSON(&req); err != nil { + AbortWithError(c, http.StatusBadRequest, NewErrBadRequest(err)) + return + } - // 1. Look up attempt by ID - attemptRecord, err := h.logStore.RetrieveAttempt(c.Request.Context(), logstore.RetrieveAttemptRequest{ - TenantID: tenant.ID, - AttemptID: attemptID, + tenantID := tenantIDFromContext(c) + + // 1. Look up event by ID + event, err := h.logStore.RetrieveEvent(c.Request.Context(), logstore.RetrieveEventRequest{ + TenantID: tenantID, + EventID: req.EventID, }) if err != nil { AbortWithError(c, http.StatusInternalServerError, NewErrInternalServer(err)) return } - if attemptRecord == nil { - AbortWithError(c, http.StatusNotFound, NewErrNotFound("attempt")) + if event == nil { + AbortWithError(c, http.StatusNotFound, NewErrNotFound("event")) return } + // Authz: JWT tenant can only retry their own events + if tenant := tenantFromContext(c); tenant != nil { + if event.TenantID != tenant.ID { + AbortWithError(c, http.StatusNotFound, NewErrNotFound("event")) + return + } + } + // 2. Check destination exists and is enabled - destination, err := h.tenantStore.RetrieveDestination(c.Request.Context(), tenant.ID, attemptRecord.Attempt.DestinationID) + destination, err := h.tenantStore.RetrieveDestination(c.Request.Context(), event.TenantID, req.DestinationID) if err != nil { AbortWithError(c, http.StatusInternalServerError, NewErrInternalServer(err)) return @@ -76,8 +93,16 @@ func (h *RetryHandlers) RetryAttempt(c *gin.Context) { return } + if !destination.MatchEvent(*event) { + AbortWithError(c, http.StatusBadRequest, ErrorResponse{ + Code: http.StatusBadRequest, + Message: "destination does not match event", + }) + return + } + // 3. Create and publish manual delivery task - task := models.NewManualDeliveryTask(*attemptRecord.Event, attemptRecord.Attempt.DestinationID) + task := models.NewManualDeliveryTask(*event, req.DestinationID) if err := h.deliveryMQ.Publish(c.Request.Context(), task); err != nil { AbortWithError(c, http.StatusInternalServerError, NewErrInternalServer(err)) @@ -85,10 +110,9 @@ func (h *RetryHandlers) RetryAttempt(c *gin.Context) { } h.logger.Ctx(c.Request.Context()).Audit("manual retry initiated", - zap.String("attempt_id", attemptID), - zap.String("event_id", attemptRecord.Event.ID), - zap.String("tenant_id", tenant.ID), - zap.String("destination_id", attemptRecord.Attempt.DestinationID), + zap.String("event_id", event.ID), + zap.String("tenant_id", event.TenantID), + zap.String("destination_id", req.DestinationID), zap.String("destination_type", destination.Type)) c.JSON(http.StatusAccepted, gin.H{ diff --git a/internal/apirouter/retry_handlers_test.go b/internal/apirouter/retry_handlers_test.go index 070873a8..beb6ceeb 100644 --- a/internal/apirouter/retry_handlers_test.go +++ b/internal/apirouter/retry_handlers_test.go @@ -1,6 +1,7 @@ package apirouter_test import ( + "bytes" "context" "encoding/json" "net/http" @@ -15,7 +16,15 @@ import ( "github.com/stretchr/testify/require" ) -func TestRetryAttempt(t *testing.T) { +func retryBody(eventID, destinationID string) *bytes.Buffer { + body, _ := json.Marshal(map[string]string{ + "event_id": eventID, + "destination_id": destinationID, + }) + return bytes.NewBuffer(body) +} + +func TestRetry(t *testing.T) { t.Parallel() result := setupTestRouterFull(t, "", "") @@ -35,7 +44,7 @@ func TestRetryAttempt(t *testing.T) { CreatedAt: time.Now(), })) - // Seed an attempt event + // Seed an event eventID := idgen.Event() attemptID := idgen.Attempt() eventTime := time.Now().Add(-1 * time.Hour).Truncate(time.Millisecond) @@ -59,7 +68,7 @@ func TestRetryAttempt(t *testing.T) { require.NoError(t, result.logStore.InsertMany(context.Background(), []*models.LogEntry{{Event: event, Attempt: attempt}})) - t.Run("should retry attempt successfully with full event data", func(t *testing.T) { + t.Run("should retry successfully with full event data", func(t *testing.T) { // Subscribe to deliveryMQ to capture published task ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -69,7 +78,8 @@ func TestRetryAttempt(t *testing.T) { // Trigger manual retry w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", baseAPIPath+"/tenants/"+tenantID+"/attempts/"+attemptID+"/retry", nil) + req, _ := http.NewRequest("POST", baseAPIPath+"/retry", retryBody(eventID, destinationID)) + req.Header.Set("Content-Type", "application/json") result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusAccepted, w.Code) @@ -97,22 +107,44 @@ func TestRetryAttempt(t *testing.T) { msg.Ack() }) - t.Run("should return 404 for non-existent attempt", func(t *testing.T) { + t.Run("should return 404 for non-existent event", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", baseAPIPath+"/tenants/"+tenantID+"/attempts/nonexistent/retry", nil) + req, _ := http.NewRequest("POST", baseAPIPath+"/retry", retryBody("nonexistent", destinationID)) + req.Header.Set("Content-Type", "application/json") result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusNotFound, w.Code) }) - t.Run("should return 404 for non-existent tenant", func(t *testing.T) { + t.Run("should return 404 for non-existent destination", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", baseAPIPath+"/tenants/nonexistent/attempts/"+attemptID+"/retry", nil) + req, _ := http.NewRequest("POST", baseAPIPath+"/retry", retryBody(eventID, "nonexistent")) + req.Header.Set("Content-Type", "application/json") result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusNotFound, w.Code) }) + t.Run("should return 400 when missing event_id", func(t *testing.T) { + body, _ := json.Marshal(map[string]string{"destination_id": destinationID}) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", baseAPIPath+"/retry", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + result.router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("should return 400 when missing destination_id", func(t *testing.T) { + body, _ := json.Marshal(map[string]string{"event_id": eventID}) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", baseAPIPath+"/retry", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + result.router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + t.Run("should return 400 when destination is disabled", func(t *testing.T) { // Create a new destination that's disabled disabledDestinationID := idgen.Destination() @@ -126,7 +158,7 @@ func TestRetryAttempt(t *testing.T) { DisabledAt: &disabledAt, })) - // Create an attempt for the disabled destination + // Create an event for the disabled destination disabledEventID := idgen.Event() disabledAttemptID := idgen.Attempt() @@ -149,7 +181,8 @@ func TestRetryAttempt(t *testing.T) { require.NoError(t, result.logStore.InsertMany(context.Background(), []*models.LogEntry{{Event: disabledEvent, Attempt: disabledAttempt}})) w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", baseAPIPath+"/tenants/"+tenantID+"/attempts/"+disabledAttemptID+"/retry", nil) + req, _ := http.NewRequest("POST", baseAPIPath+"/retry", retryBody(disabledEventID, disabledDestinationID)) + req.Header.Set("Content-Type", "application/json") result.router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) @@ -158,4 +191,49 @@ func TestRetryAttempt(t *testing.T) { require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) assert.Equal(t, "Destination is disabled", response["message"]) }) + + t.Run("should return 400 when destination does not match event", func(t *testing.T) { + // Create a destination that only matches "order.created" topic + mismatchDestinationID := idgen.Destination() + require.NoError(t, result.tenantStore.UpsertDestination(context.Background(), models.Destination{ + ID: mismatchDestinationID, + TenantID: tenantID, + Type: "webhook", + Topics: []string{"order.created"}, + CreatedAt: time.Now(), + })) + + // Create an event with a different topic + mismatchEventID := idgen.Event() + mismatchAttemptID := idgen.Attempt() + + mismatchEvent := testutil.EventFactory.AnyPointer( + testutil.EventFactory.WithID(mismatchEventID), + testutil.EventFactory.WithTenantID(tenantID), + testutil.EventFactory.WithDestinationID(mismatchDestinationID), + testutil.EventFactory.WithTopic("user.updated"), + testutil.EventFactory.WithTime(eventTime), + ) + + mismatchAttempt := testutil.AttemptFactory.AnyPointer( + testutil.AttemptFactory.WithID(mismatchAttemptID), + testutil.AttemptFactory.WithEventID(mismatchEventID), + testutil.AttemptFactory.WithDestinationID(mismatchDestinationID), + testutil.AttemptFactory.WithStatus("failed"), + testutil.AttemptFactory.WithTime(attemptTime), + ) + + require.NoError(t, result.logStore.InsertMany(context.Background(), []*models.LogEntry{{Event: mismatchEvent, Attempt: mismatchAttempt}})) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", baseAPIPath+"/retry", retryBody(mismatchEventID, mismatchDestinationID)) + req.Header.Set("Content-Type", "application/json") + result.router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + + var response map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + assert.Equal(t, "destination does not match event", response["message"]) + }) } diff --git a/internal/apirouter/router.go b/internal/apirouter/router.go index 6e679049..e81728dd 100644 --- a/internal/apirouter/router.go +++ b/internal/apirouter/router.go @@ -30,12 +30,12 @@ const ( ) type RouteDefinition struct { - Method string - Path string - Handler gin.HandlerFunc - AuthMode AuthMode - TenantScoped bool - Middlewares []gin.HandlerFunc + Method string + Path string + Handler gin.HandlerFunc + AuthMode AuthMode + RequireTenant bool + Middlewares []gin.HandlerFunc } type RouterConfig struct { @@ -49,10 +49,6 @@ type RouterConfig struct { GinMode string } -func (c RouterConfig) PortalEnabled() bool { - return c.APIKey != "" && c.JWTSecret != "" -} - // registerRoutes registers routes to the given router based on route definitions and config func registerRoutes(router *gin.RouterGroup, cfg RouterConfig, tenantRetriever TenantRetriever, routes []RouteDefinition) { for _, route := range routes { @@ -67,18 +63,20 @@ func buildMiddlewareChain(cfg RouterConfig, tenantRetriever TenantRetriever, def // Add auth middleware based on mode switch def.AuthMode { case AuthAdmin: - chain = append(chain, APIKeyAuthMiddleware(cfg.APIKey)) + chain = append(chain, AdminMiddleware(cfg.APIKey)) + if def.RequireTenant { + chain = append(chain, resolveTenantMiddleware(tenantRetriever, true)) + } case AuthAuthenticated: chain = append(chain, AuthenticatedMiddleware(cfg.APIKey, cfg.JWTSecret)) + // Always add tenant resolution for authenticated routes: + // - RequireTenant routes: resolve from param, 404 if missing + // - JWT users on non-RequireTenant routes: resolve from context tenantID + chain = append(chain, resolveTenantMiddleware(tenantRetriever, def.RequireTenant)) case AuthPublic: // no auth middleware } - // Auto-apply tenant middleware when route is tenant-scoped - if def.TenantScoped { - chain = append(chain, RequireTenantMiddleware(tenantRetriever)) - } - // Add custom middlewares chain = append(chain, def.Middlewares...) @@ -140,66 +138,45 @@ func NewRouter( retryHandlers := NewRetryHandlers(logger, tenantStore, logStore, deliveryMQ) topicHandlers := NewTopicHandlers(logger, cfg.Topics) - // Non-tenant routes (no :tenantID in path) - nonTenantRoutes := []RouteDefinition{ - {Method: http.MethodPost, Path: "/publish", Handler: publishHandlers.Ingest, AuthMode: AuthAdmin}, - {Method: http.MethodGet, Path: "/tenants", Handler: tenantHandlers.List, AuthMode: AuthAdmin}, - {Method: http.MethodGet, Path: "/events", Handler: logHandlers.AdminListEvents, AuthMode: AuthAdmin}, - {Method: http.MethodGet, Path: "/attempts", Handler: logHandlers.AdminListAttempts, AuthMode: AuthAdmin}, - } - - // Tenant routes (registered under /tenants group) - tenantRoutes := []RouteDefinition{ - // Tenant CRUD - {Method: http.MethodPut, Path: "/:tenantID", Handler: tenantHandlers.Upsert, AuthMode: AuthAdmin}, - {Method: http.MethodGet, Path: "/:tenantID", Handler: tenantHandlers.Retrieve, AuthMode: AuthAuthenticated, TenantScoped: true}, - {Method: http.MethodDelete, Path: "/:tenantID", Handler: tenantHandlers.Delete, AuthMode: AuthAuthenticated, TenantScoped: true}, - - // Tenant-agnostic routes (no tenant lookup needed) - {Method: http.MethodGet, Path: "/:tenantID/destination-types", Handler: destinationHandlers.ListProviderMetadata, AuthMode: AuthAuthenticated}, - {Method: http.MethodGet, Path: "/:tenantID/destination-types/:type", Handler: destinationHandlers.RetrieveProviderMetadata, AuthMode: AuthAuthenticated}, - {Method: http.MethodGet, Path: "/:tenantID/topics", Handler: topicHandlers.List, AuthMode: AuthAuthenticated}, - - // Destination routes - {Method: http.MethodGet, Path: "/:tenantID/destinations", Handler: destinationHandlers.List, AuthMode: AuthAuthenticated, TenantScoped: true}, - {Method: http.MethodPost, Path: "/:tenantID/destinations", Handler: destinationHandlers.Create, AuthMode: AuthAuthenticated, TenantScoped: true}, - {Method: http.MethodGet, Path: "/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Retrieve, AuthMode: AuthAuthenticated, TenantScoped: true}, - {Method: http.MethodPatch, Path: "/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Update, AuthMode: AuthAuthenticated, TenantScoped: true}, - {Method: http.MethodDelete, Path: "/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Delete, AuthMode: AuthAuthenticated, TenantScoped: true}, - {Method: http.MethodPut, Path: "/:tenantID/destinations/:destinationID/enable", Handler: destinationHandlers.Enable, AuthMode: AuthAuthenticated, TenantScoped: true}, - {Method: http.MethodPut, Path: "/:tenantID/destinations/:destinationID/disable", Handler: destinationHandlers.Disable, AuthMode: AuthAuthenticated, TenantScoped: true}, - - // Destination-scoped attempt routes - {Method: http.MethodGet, Path: "/:tenantID/destinations/:destinationID/attempts", Handler: logHandlers.ListDestinationAttempts, AuthMode: AuthAuthenticated, TenantScoped: true}, - {Method: http.MethodGet, Path: "/:tenantID/destinations/:destinationID/attempts/:attemptID", Handler: logHandlers.RetrieveAttempt, AuthMode: AuthAuthenticated, TenantScoped: true}, - {Method: http.MethodPost, Path: "/:tenantID/destinations/:destinationID/attempts/:attemptID/retry", Handler: retryHandlers.RetryAttempt, AuthMode: AuthAuthenticated, TenantScoped: true}, - - // Event routes - {Method: http.MethodGet, Path: "/:tenantID/events", Handler: logHandlers.ListEvents, AuthMode: AuthAuthenticated, TenantScoped: true}, - {Method: http.MethodGet, Path: "/:tenantID/events/:eventID", Handler: logHandlers.RetrieveEvent, AuthMode: AuthAuthenticated, TenantScoped: true}, - - // Attempt routes - {Method: http.MethodGet, Path: "/:tenantID/attempts", Handler: logHandlers.ListAttempts, AuthMode: AuthAuthenticated, TenantScoped: true}, - {Method: http.MethodGet, Path: "/:tenantID/attempts/:attemptID", Handler: logHandlers.RetrieveAttempt, AuthMode: AuthAuthenticated, TenantScoped: true}, - {Method: http.MethodPost, Path: "/:tenantID/attempts/:attemptID/retry", Handler: retryHandlers.RetryAttempt, AuthMode: AuthAuthenticated, TenantScoped: true}, - } - - // Portal routes (conditionally appended) - portalRoutes := []RouteDefinition{ - {Method: http.MethodGet, Path: "/:tenantID/token", Handler: tenantHandlers.RetrieveToken, AuthMode: AuthAdmin, TenantScoped: true}, - {Method: http.MethodGet, Path: "/:tenantID/portal", Handler: tenantHandlers.RetrievePortal, AuthMode: AuthAdmin, TenantScoped: true}, - } + routes := []RouteDefinition{ + // Schemas & Topics + {Method: http.MethodGet, Path: "/destination-types", Handler: destinationHandlers.ListProviderMetadata, AuthMode: AuthAuthenticated}, + {Method: http.MethodGet, Path: "/destination-types/:type", Handler: destinationHandlers.RetrieveProviderMetadata, AuthMode: AuthAuthenticated}, + {Method: http.MethodGet, Path: "/topics", Handler: topicHandlers.List, AuthMode: AuthAuthenticated}, - if cfg.PortalEnabled() { - tenantRoutes = append(tenantRoutes, portalRoutes...) + // Publish / Retry + {Method: http.MethodPost, Path: "/publish", Handler: publishHandlers.Ingest, AuthMode: AuthAdmin}, + {Method: http.MethodPost, Path: "/retry", Handler: retryHandlers.Retry, AuthMode: AuthAuthenticated}, + + // Tenants + {Method: http.MethodGet, Path: "/tenants", Handler: tenantHandlers.List, AuthMode: AuthAuthenticated}, + {Method: http.MethodPut, Path: "/tenants/:tenantID", Handler: tenantHandlers.Upsert, AuthMode: AuthAdmin}, + {Method: http.MethodGet, Path: "/tenants/:tenantID", Handler: tenantHandlers.Retrieve, AuthMode: AuthAuthenticated, RequireTenant: true}, + {Method: http.MethodDelete, Path: "/tenants/:tenantID", Handler: tenantHandlers.Delete, AuthMode: AuthAuthenticated, RequireTenant: true}, + {Method: http.MethodGet, Path: "/tenants/:tenantID/token", Handler: tenantHandlers.RetrieveToken, AuthMode: AuthAdmin, RequireTenant: true}, + {Method: http.MethodGet, Path: "/tenants/:tenantID/portal", Handler: tenantHandlers.RetrievePortal, AuthMode: AuthAdmin, RequireTenant: true}, + + // Destinations + {Method: http.MethodGet, Path: "/tenants/:tenantID/destinations", Handler: destinationHandlers.List, AuthMode: AuthAuthenticated, RequireTenant: true}, + {Method: http.MethodPost, Path: "/tenants/:tenantID/destinations", Handler: destinationHandlers.Create, AuthMode: AuthAuthenticated, RequireTenant: true}, + {Method: http.MethodGet, Path: "/tenants/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Retrieve, AuthMode: AuthAuthenticated, RequireTenant: true}, + {Method: http.MethodPatch, Path: "/tenants/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Update, AuthMode: AuthAuthenticated, RequireTenant: true}, + {Method: http.MethodDelete, Path: "/tenants/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Delete, AuthMode: AuthAuthenticated, RequireTenant: true}, + {Method: http.MethodPut, Path: "/tenants/:tenantID/destinations/:destinationID/enable", Handler: destinationHandlers.Enable, AuthMode: AuthAuthenticated, RequireTenant: true}, + {Method: http.MethodPut, Path: "/tenants/:tenantID/destinations/:destinationID/disable", Handler: destinationHandlers.Disable, AuthMode: AuthAuthenticated, RequireTenant: true}, + {Method: http.MethodGet, Path: "/tenants/:tenantID/destinations/:destinationID/attempts", Handler: logHandlers.ListDestinationAttempts, AuthMode: AuthAuthenticated, RequireTenant: true}, + {Method: http.MethodGet, Path: "/tenants/:tenantID/destinations/:destinationID/attempts/:attemptID", Handler: logHandlers.RetrieveAttempt, AuthMode: AuthAuthenticated, RequireTenant: true}, + + // Events + {Method: http.MethodGet, Path: "/events", Handler: logHandlers.ListEvents, AuthMode: AuthAuthenticated}, + {Method: http.MethodGet, Path: "/events/:eventID", Handler: logHandlers.RetrieveEvent, AuthMode: AuthAuthenticated}, + + // Attempts + {Method: http.MethodGet, Path: "/attempts", Handler: logHandlers.ListAttempts, AuthMode: AuthAuthenticated}, + {Method: http.MethodGet, Path: "/attempts/:attemptID", Handler: logHandlers.RetrieveAttempt, AuthMode: AuthAuthenticated}, } - // Register non-tenant routes at root - registerRoutes(apiRouter, cfg, tenantStore, nonTenantRoutes) - - // Register tenant routes under /tenants prefix - tenantsGroup := apiRouter.Group("/tenants") - registerRoutes(tenantsGroup, cfg, tenantStore, tenantRoutes) + registerRoutes(apiRouter, cfg, tenantStore, routes) // Register dev routes if gin.Mode() == gin.DebugMode { diff --git a/internal/apirouter/router_test.go b/internal/apirouter/router_test.go index 307c96d3..1c80bb27 100644 --- a/internal/apirouter/router_test.go +++ b/internal/apirouter/router_test.go @@ -167,7 +167,7 @@ func TestRouterWithAPIKey(t *testing.T) { assert.Equal(t, http.StatusNotFound, w.Code) }) - t.Run("should allow tenant-auth request to tenant routes", func(t *testing.T) { + t.Run("should return 401 for JWT tenant that doesn't exist in DB", func(t *testing.T) { t.Parallel() w := httptest.NewRecorder() @@ -175,9 +175,8 @@ func TestRouterWithAPIKey(t *testing.T) { req.Header.Set("Authorization", "Bearer "+validToken) router.ServeHTTP(w, req) - // A bit awkward that the tenant is not found, but the request is authenticated - // and the 404 response is handled by the handler which is what we're testing here (routing). - assert.Equal(t, http.StatusNotFound, w.Code) + // JWT references a tenant that doesn't exist — this is an auth failure, not a 404. + assert.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("should block invalid tenant-auth request to tenant routes", func(t *testing.T) { diff --git a/internal/apirouter/tenant_handlers.go b/internal/apirouter/tenant_handlers.go index 71977a0e..ce0f4f57 100644 --- a/internal/apirouter/tenant_handlers.go +++ b/internal/apirouter/tenant_handlers.go @@ -94,6 +94,15 @@ func (h *TenantHandlers) Retrieve(c *gin.Context) { } func (h *TenantHandlers) List(c *gin.Context) { + // Authz: JWT users can only see their own tenant + if tenant := tenantFromContext(c); tenant != nil { + c.JSON(http.StatusOK, tenantstore.TenantPaginatedResult{ + Models: []models.Tenant{*tenant}, + Count: 1, + }) + return + } + // Parse and validate cursors (next/prev are mutually exclusive) cursors, errResp := ParseCursors(c) if errResp != nil { From c2c17fb6cc2ad552970465464832b7fc6f480b7c Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sat, 31 Jan 2026 19:39:30 +0700 Subject: [PATCH 09/34] test: remove old test files --- internal/apirouter/auth_middleware_test.go | 518 -------------- .../apirouter/destination_handlers_test.go | 67 -- internal/apirouter/log_handlers_test.go | 658 ------------------ internal/apirouter/publish_handlers_test.go | 44 -- internal/apirouter/retry_handlers_test.go | 239 ------- internal/apirouter/router_test.go | 393 ----------- internal/apirouter/tenant_handlers_test.go | 336 --------- 7 files changed, 2255 deletions(-) delete mode 100644 internal/apirouter/auth_middleware_test.go delete mode 100644 internal/apirouter/destination_handlers_test.go delete mode 100644 internal/apirouter/log_handlers_test.go delete mode 100644 internal/apirouter/publish_handlers_test.go delete mode 100644 internal/apirouter/retry_handlers_test.go delete mode 100644 internal/apirouter/router_test.go delete mode 100644 internal/apirouter/tenant_handlers_test.go diff --git a/internal/apirouter/auth_middleware_test.go b/internal/apirouter/auth_middleware_test.go deleted file mode 100644 index b6fd6390..00000000 --- a/internal/apirouter/auth_middleware_test.go +++ /dev/null @@ -1,518 +0,0 @@ -package apirouter_test - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/golang-jwt/jwt/v5" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/hookdeck/outpost/internal/apirouter" - "github.com/hookdeck/outpost/internal/idgen" - "github.com/hookdeck/outpost/internal/models" -) - -func TestPublicRouter(t *testing.T) { - t.Parallel() - - const apiKey = "" - router, _, _ := setupTestRouter(t, apiKey, "") - - t.Run("should accept requests without a token", func(t *testing.T) { - t.Parallel() - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/topics", nil) - router.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - }) - - t.Run("should accept requests with an invalid authorization token", func(t *testing.T) { - t.Parallel() - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/topics", nil) - req.Header.Set("Authorization", "invalid key") - router.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - }) - - t.Run("should accept requests with a valid authorization token", func(t *testing.T) { - t.Parallel() - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/topics", nil) - req.Header.Set("Authorization", "Bearer key") - router.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - }) -} - -func TestPrivateAPIKeyRouter(t *testing.T) { - t.Parallel() - - const apiKey = "key" - router, _, _ := setupTestRouter(t, apiKey, "") - - t.Run("should reject requests without a token", func(t *testing.T) { - t.Parallel() - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", baseAPIPath+"/tenants/tenant_id", nil) - router.ServeHTTP(w, req) - assert.Equal(t, http.StatusUnauthorized, w.Code) - }) - - t.Run("should reject requests with an malformed authorization header", func(t *testing.T) { - t.Parallel() - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", baseAPIPath+"/tenants/tenant_id", nil) - req.Header.Set("Authorization", "invalid key") - router.ServeHTTP(w, req) - assert.Equal(t, http.StatusUnauthorized, w.Code) - }) - - t.Run("should reject requests with an incorrect authorization token", func(t *testing.T) { - t.Parallel() - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", baseAPIPath+"/tenants/tenant_id", nil) - req.Header.Set("Authorization", "Bearer invalid") - router.ServeHTTP(w, req) - assert.Equal(t, http.StatusUnauthorized, w.Code) - }) - - t.Run("should accept requests with a valid authorization token", func(t *testing.T) { - t.Parallel() - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", baseAPIPath+"/tenants/tenant_id", nil) - req.Header.Set("Authorization", "Bearer "+apiKey) - router.ServeHTTP(w, req) - assert.Equal(t, http.StatusCreated, w.Code) - }) -} - -func TestAuthenticatedMiddleware(t *testing.T) { - gin.SetMode(gin.TestMode) - t.Parallel() - - const jwtSecret = "jwt_secret" - const apiKey = "api_key" - const tenantID = "test_tenant" - - t.Run("should reject when JWT tenantID doesn't match param", func(t *testing.T) { - t.Parallel() - - // Setup - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Params = []gin.Param{{Key: "tenantID", Value: "different_tenant"}} - - // Create JWT token for tenantID - token, err := apirouter.JWT.New(jwtSecret, apirouter.JWTClaims{TenantID: tenantID}) - if err != nil { - t.Fatal(err) - } - - // Set auth header - c.Request = httptest.NewRequest("GET", "/", nil) - c.Request.Header.Set("Authorization", "Bearer "+token) - - // Test - handler := apirouter.AuthenticatedMiddleware(apiKey, jwtSecret) - handler(c) - - assert.Equal(t, http.StatusUnauthorized, c.Writer.Status()) - }) - - t.Run("should accept when JWT tenantID matches param", func(t *testing.T) { - t.Parallel() - - // Setup - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Params = []gin.Param{{Key: "tenantID", Value: tenantID}} - - // Create JWT token for tenantID - token, err := apirouter.JWT.New(jwtSecret, apirouter.JWTClaims{TenantID: tenantID}) - if err != nil { - t.Fatal(err) - } - - // Set auth header - c.Request = httptest.NewRequest("GET", "/", nil) - c.Request.Header.Set("Authorization", "Bearer "+token) - - // Create a middleware chain - var contextTenantID string - handler := apirouter.AuthenticatedMiddleware(apiKey, jwtSecret) - nextHandler := func(c *gin.Context) { - val, exists := c.Get("tenantID") - if exists { - contextTenantID = val.(string) - } - } - - // Test - handler(c) - if c.Writer.Status() == http.StatusUnauthorized { - t.Fatal("handler returned unauthorized") - } - nextHandler(c) - - assert.Equal(t, tenantID, contextTenantID) - }) - - t.Run("should reject expired JWT token", func(t *testing.T) { - t.Parallel() - - // Setup - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - - // Create expired JWT token - token := newExpiredJWTToken(t, jwtSecret, tenantID) - - // Set auth header - c.Request = httptest.NewRequest("GET", "/", nil) - c.Request.Header.Set("Authorization", "Bearer "+token) - - // Test - handler := apirouter.AuthenticatedMiddleware(apiKey, jwtSecret) - handler(c) - - assert.Equal(t, http.StatusUnauthorized, c.Writer.Status()) - }) - - t.Run("should accept when using API key regardless of tenantID param", func(t *testing.T) { - t.Parallel() - - // Setup - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Params = []gin.Param{{Key: "tenantID", Value: "any_tenant"}} - - // Set auth header with API key - c.Request = httptest.NewRequest("GET", "/", nil) - c.Request.Header.Set("Authorization", "Bearer "+apiKey) - - // Test - handler := apirouter.AuthenticatedMiddleware(apiKey, jwtSecret) - handler(c) - - assert.NotEqual(t, http.StatusUnauthorized, c.Writer.Status()) - }) -} - -func newJWTToken(t *testing.T, secret string, tenantID string) string { - token, err := apirouter.JWT.New(secret, apirouter.JWTClaims{TenantID: tenantID}) - if err != nil { - t.Fatal(err) - } - return token -} - -func newExpiredJWTToken(t *testing.T, secret string, tenantID string) string { - now := time.Now() - jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ - "iss": "outpost", - "sub": tenantID, - "iat": now.Add(-2 * time.Hour).Unix(), - "exp": now.Add(-1 * time.Hour).Unix(), - }) - token, err := jwtToken.SignedString([]byte(secret)) - if err != nil { - t.Fatal(err) - } - return token -} - -func TestResolveTenantMiddleware(t *testing.T) { - t.Parallel() - - const apiKey = "" - router, _, redisClient := setupTestRouter(t, apiKey, "") - - t.Run("should reject requests without a tenant", func(t *testing.T) { - t.Parallel() - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/invalid_tenant_id/destinations", nil) - router.ServeHTTP(w, req) - assert.Equal(t, http.StatusNotFound, w.Code) - }) - - t.Run("should allow requests with a valid tenant", func(t *testing.T) { - t.Parallel() - - tenant := models.Tenant{ - ID: idgen.String(), - } - tenantStore := setupTestTenantStore(t, redisClient) - err := tenantStore.UpsertTenant(context.Background(), tenant) - require.Nil(t, err) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenant.ID+"/destinations", nil) - router.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - }) -} - -func TestTenantJWTAuthMiddleware(t *testing.T) { - gin.SetMode(gin.TestMode) - t.Parallel() - - tests := []struct { - name string - apiKey string - jwtSecret string - header string - paramTenantID string - wantStatus int - wantTenantID string - }{ - { - name: "should return 404 when apiKey is empty", - apiKey: "", - jwtSecret: "secret", - header: "Bearer token", - wantStatus: http.StatusNotFound, - }, - { - name: "should return 404 when jwtSecret is empty", - apiKey: "key", - jwtSecret: "", - header: "Bearer token", - wantStatus: http.StatusNotFound, - }, - { - name: "should return 401 when no auth header", - apiKey: "key", - jwtSecret: "secret", - wantStatus: http.StatusUnauthorized, - wantTenantID: "", - }, - { - name: "should return 401 when invalid auth header", - apiKey: "key", - jwtSecret: "secret", - header: "invalid", - wantStatus: http.StatusUnauthorized, - wantTenantID: "", - }, - { - name: "should return 401 when invalid token", - apiKey: "key", - jwtSecret: "secret", - header: "Bearer invalid", - wantStatus: http.StatusUnauthorized, - wantTenantID: "", - }, - { - name: "should return 200 when valid token", - apiKey: "key", - jwtSecret: "secret", - header: "Bearer " + newJWTToken(t, "secret", "tenant-id"), - wantStatus: http.StatusOK, - wantTenantID: "tenant-id", - }, - { - name: "should return 401 when tenantID param doesn't match token", - apiKey: "key", - jwtSecret: "secret", - header: "Bearer " + newJWTToken(t, "secret", "tenant-id"), - paramTenantID: "other-tenant-id", - wantStatus: http.StatusUnauthorized, - wantTenantID: "", - }, - { - name: "should return 401 when token is expired", - apiKey: "key", - jwtSecret: "secret", - header: "Bearer " + newExpiredJWTToken(t, "secret", "tenant-id"), - wantStatus: http.StatusUnauthorized, - wantTenantID: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest(http.MethodGet, "/", nil) - if tt.header != "" { - c.Request.Header.Set("Authorization", tt.header) - } - if tt.paramTenantID != "" { - c.Params = []gin.Param{{Key: "tenantID", Value: tt.paramTenantID}} - } - - handler := apirouter.TenantJWTAuthMiddleware(tt.apiKey, tt.jwtSecret) - handler(c) - - t.Logf("Test case: %s, Expected: %d, Got: %d", tt.name, tt.wantStatus, w.Code) - assert.Equal(t, tt.wantStatus, w.Code) - if tt.wantTenantID != "" { - tenantID, exists := c.Get("tenantID") - assert.True(t, exists) - assert.Equal(t, tt.wantTenantID, tenantID) - } - }) - } -} - -func TestAuthRole(t *testing.T) { - gin.SetMode(gin.TestMode) - t.Parallel() - - t.Run("AdminMiddleware", func(t *testing.T) { - t.Run("should set RoleAdmin when apiKey is empty", func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest(http.MethodGet, "/", nil) - - handler := apirouter.AdminMiddleware("") - var role string - nextHandler := func(c *gin.Context) { - val, exists := c.Get("authRole") - if exists { - role = val.(string) - } - } - - handler(c) - nextHandler(c) - - assert.Equal(t, apirouter.RoleAdmin, role) - }) - - t.Run("should set RoleAdmin when valid API key", func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest(http.MethodGet, "/", nil) - c.Request.Header.Set("Authorization", "Bearer key") - - handler := apirouter.AdminMiddleware("key") - var role string - nextHandler := func(c *gin.Context) { - val, exists := c.Get("authRole") - if exists { - role = val.(string) - } - } - - handler(c) - nextHandler(c) - - assert.Equal(t, apirouter.RoleAdmin, role) - }) - }) - - t.Run("AuthenticatedMiddleware", func(t *testing.T) { - t.Run("should set RoleAdmin when apiKey is empty", func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest(http.MethodGet, "/", nil) - - handler := apirouter.AuthenticatedMiddleware("", "jwt_secret") - var role string - nextHandler := func(c *gin.Context) { - val, exists := c.Get("authRole") - if exists { - role = val.(string) - } - } - - handler(c) - nextHandler(c) - - assert.Equal(t, apirouter.RoleAdmin, role) - }) - - t.Run("should set RoleAdmin when using API key", func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest(http.MethodGet, "/", nil) - c.Request.Header.Set("Authorization", "Bearer key") - - handler := apirouter.AuthenticatedMiddleware("key", "jwt_secret") - var role string - nextHandler := func(c *gin.Context) { - val, exists := c.Get("authRole") - if exists { - role = val.(string) - } - } - - handler(c) - nextHandler(c) - - assert.Equal(t, apirouter.RoleAdmin, role) - }) - - t.Run("should set RoleTenant when using valid JWT", func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest(http.MethodGet, "/", nil) - token := newJWTToken(t, "jwt_secret", "tenant-id") - c.Request.Header.Set("Authorization", "Bearer "+token) - - handler := apirouter.AuthenticatedMiddleware("key", "jwt_secret") - var role string - nextHandler := func(c *gin.Context) { - val, exists := c.Get("authRole") - if exists { - role = val.(string) - } - } - - handler(c) - nextHandler(c) - - assert.Equal(t, apirouter.RoleTenant, role) - }) - }) - - t.Run("TenantJWTAuthMiddleware", func(t *testing.T) { - t.Run("should set RoleTenant when using valid JWT", func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest(http.MethodGet, "/", nil) - token := newJWTToken(t, "jwt_secret", "tenant-id") - c.Request.Header.Set("Authorization", "Bearer "+token) - - handler := apirouter.TenantJWTAuthMiddleware("key", "jwt_secret") - var role string - nextHandler := func(c *gin.Context) { - val, exists := c.Get("authRole") - if exists { - role = val.(string) - } - } - - handler(c) - nextHandler(c) - - assert.Equal(t, apirouter.RoleTenant, role) - }) - - t.Run("should not set role when apiKey is empty", func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest(http.MethodGet, "/", nil) - token := newJWTToken(t, "jwt_secret", "tenant-id") - c.Request.Header.Set("Authorization", "Bearer "+token) - - handler := apirouter.TenantJWTAuthMiddleware("", "jwt_secret") - var roleExists bool - nextHandler := func(c *gin.Context) { - _, roleExists = c.Get("authRole") - } - - handler(c) - nextHandler(c) - - assert.False(t, roleExists) - }) - }) -} diff --git a/internal/apirouter/destination_handlers_test.go b/internal/apirouter/destination_handlers_test.go deleted file mode 100644 index da71d666..00000000 --- a/internal/apirouter/destination_handlers_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package apirouter_test - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/hookdeck/outpost/internal/idgen" - "github.com/hookdeck/outpost/internal/models" - "github.com/stretchr/testify/assert" -) - -func TestDestinationCreateHandler(t *testing.T) { - t.Parallel() - - router, _, redisClient := setupTestRouter(t, "", "") - tenantStore := setupTestTenantStore(t, redisClient) - - t.Run("should set updated_at equal to created_at on creation", func(t *testing.T) { - t.Parallel() - - // Setup - create tenant first - tenantID := idgen.String() - tenant := models.Tenant{ - ID: tenantID, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - err := tenantStore.UpsertTenant(context.Background(), tenant) - if err != nil { - t.Fatal(err) - } - - // Create destination request - body := map[string]any{ - "type": "webhook", - "topics": []string{"*"}, - "config": map[string]string{ - "url": "https://example.com/webhook", - }, - } - bodyBytes, _ := json.Marshal(body) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", baseAPIPath+"/tenants/"+tenantID+"/destinations", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - var response map[string]any - json.Unmarshal(w.Body.Bytes(), &response) - - assert.Equal(t, http.StatusCreated, w.Code) - assert.NotEqual(t, "", response["created_at"]) - assert.NotEqual(t, "", response["updated_at"]) - assert.Equal(t, response["created_at"], response["updated_at"]) - - // Cleanup - if destID, ok := response["id"].(string); ok { - tenantStore.DeleteDestination(context.Background(), tenantID, destID) - } - tenantStore.DeleteTenant(context.Background(), tenantID) - }) -} diff --git a/internal/apirouter/log_handlers_test.go b/internal/apirouter/log_handlers_test.go deleted file mode 100644 index 1956f6d5..00000000 --- a/internal/apirouter/log_handlers_test.go +++ /dev/null @@ -1,658 +0,0 @@ -package apirouter_test - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/hookdeck/outpost/internal/idgen" - "github.com/hookdeck/outpost/internal/models" - "github.com/hookdeck/outpost/internal/util/testutil" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestListAttempts(t *testing.T) { - t.Parallel() - - result := setupTestRouterFull(t, "", "") - - // Create a tenant - tenantID := idgen.String() - destinationID := idgen.Destination() - require.NoError(t, result.tenantStore.UpsertTenant(context.Background(), models.Tenant{ - ID: tenantID, - CreatedAt: time.Now(), - })) - require.NoError(t, result.tenantStore.UpsertDestination(context.Background(), models.Destination{ - ID: destinationID, - TenantID: tenantID, - Type: "webhook", - Topics: []string{"*"}, - CreatedAt: time.Now(), - })) - - t.Run("should return empty list when no attempts", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID, nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - - data := response["models"].([]interface{}) - assert.Len(t, data, 0) - }) - - t.Run("should list attempts", func(t *testing.T) { - // Seed attempt events - eventID := idgen.Event() - attemptID := idgen.Attempt() - eventTime := time.Now().Add(-1 * time.Hour).Truncate(time.Millisecond) - attemptTime := eventTime.Add(100 * time.Millisecond) - - event := testutil.EventFactory.AnyPointer( - testutil.EventFactory.WithID(eventID), - testutil.EventFactory.WithTenantID(tenantID), - testutil.EventFactory.WithDestinationID(destinationID), - testutil.EventFactory.WithTopic("user.created"), - testutil.EventFactory.WithTime(eventTime), - ) - - attempt := testutil.AttemptFactory.AnyPointer( - testutil.AttemptFactory.WithID(attemptID), - testutil.AttemptFactory.WithEventID(eventID), - testutil.AttemptFactory.WithDestinationID(destinationID), - testutil.AttemptFactory.WithStatus("success"), - testutil.AttemptFactory.WithTime(attemptTime), - ) - - require.NoError(t, result.logStore.InsertMany(context.Background(), []*models.LogEntry{{Event: event, Attempt: attempt}})) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID, nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - - data := response["models"].([]interface{}) - assert.Len(t, data, 1) - - firstAttempt := data[0].(map[string]interface{}) - assert.Equal(t, attemptID, firstAttempt["id"]) - assert.Equal(t, "success", firstAttempt["status"]) - assert.Equal(t, eventID, firstAttempt["event"]) // Not included - assert.Equal(t, destinationID, firstAttempt["destination"]) - }) - - t.Run("should include event when include=event", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID+"&include=event", nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - - data := response["models"].([]interface{}) - require.Len(t, data, 1) - - firstAttempt := data[0].(map[string]interface{}) - event := firstAttempt["event"].(map[string]interface{}) - assert.NotNil(t, event["id"]) - assert.Equal(t, "user.created", event["topic"]) - // data should not be present without include=event.data - assert.Nil(t, event["data"]) - }) - - t.Run("should include event.data when include=event.data", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID+"&include=event.data", nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - - data := response["models"].([]interface{}) - require.Len(t, data, 1) - - firstAttempt := data[0].(map[string]interface{}) - event := firstAttempt["event"].(map[string]interface{}) - assert.NotNil(t, event["id"]) - assert.NotNil(t, event["data"]) // data should be present - }) - - t.Run("should filter by destination_id", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID+"&destination_id="+destinationID, nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - - data := response["models"].([]interface{}) - assert.Len(t, data, 1) - }) - - t.Run("should filter by non-existent destination_id", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID+"&destination_id=nonexistent", nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - - data := response["models"].([]interface{}) - assert.Len(t, data, 0) - }) - - t.Run("should return 404 for non-existent tenant", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/nonexistent/attempts", nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) - - t.Run("should exclude response_data by default", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID, nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - - data := response["models"].([]interface{}) - require.Len(t, data, 1) - - firstAttempt := data[0].(map[string]interface{}) - assert.Nil(t, firstAttempt["response_data"]) - }) - - t.Run("should include response_data with include=response_data", func(t *testing.T) { - // Seed an attempt with response_data - eventID := idgen.Event() - attemptID := idgen.Attempt() - eventTime := time.Now().Add(-30 * time.Minute).Truncate(time.Millisecond) - attemptTime := eventTime.Add(100 * time.Millisecond) - - event := testutil.EventFactory.AnyPointer( - testutil.EventFactory.WithID(eventID), - testutil.EventFactory.WithTenantID(tenantID), - testutil.EventFactory.WithDestinationID(destinationID), - testutil.EventFactory.WithTopic("order.created"), - testutil.EventFactory.WithTime(eventTime), - ) - - attempt := testutil.AttemptFactory.AnyPointer( - testutil.AttemptFactory.WithID(attemptID), - testutil.AttemptFactory.WithEventID(eventID), - testutil.AttemptFactory.WithDestinationID(destinationID), - testutil.AttemptFactory.WithStatus("success"), - testutil.AttemptFactory.WithTime(attemptTime), - ) - attempt.ResponseData = map[string]interface{}{ - "body": "OK", - "status": float64(200), - } - - require.NoError(t, result.logStore.InsertMany(context.Background(), []*models.LogEntry{{Event: event, Attempt: attempt}})) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID+"&include=response_data", nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - - data := response["models"].([]interface{}) - // Find the attempt we just created - var foundAttempt map[string]interface{} - for _, d := range data { - atm := d.(map[string]interface{}) - if atm["id"] == attemptID { - foundAttempt = atm - break - } - } - require.NotNil(t, foundAttempt, "attempt not found in response") - require.NotNil(t, foundAttempt["response_data"], "response_data should be included") - respData := foundAttempt["response_data"].(map[string]interface{}) - assert.Equal(t, "OK", respData["body"]) - assert.Equal(t, float64(200), respData["status"]) - }) - - t.Run("should support comma-separated include param", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID+"&include=event,response_data", nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - - data := response["models"].([]interface{}) - require.GreaterOrEqual(t, len(data), 1) - - firstAttempt := data[0].(map[string]interface{}) - // event should be included (object, not string) - event := firstAttempt["event"].(map[string]interface{}) - assert.NotNil(t, event["id"]) - assert.NotNil(t, event["topic"]) - }) - - t.Run("should return validation error for invalid dir", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID+"&dir=invalid", nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusUnprocessableEntity, w.Code) - }) - - t.Run("should accept valid dir param", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID+"&dir=asc", nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - }) - - t.Run("should cap limit at 1000", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/attempts?tenant_id="+tenantID+"&limit=5000", nil) - result.router.ServeHTTP(w, req) - - // Should succeed, limit is silently capped - assert.Equal(t, http.StatusOK, w.Code) - }) -} - -func TestRetrieveAttempt(t *testing.T) { - t.Parallel() - - result := setupTestRouterFull(t, "", "") - - // Create a tenant - tenantID := idgen.String() - destinationID := idgen.Destination() - require.NoError(t, result.tenantStore.UpsertTenant(context.Background(), models.Tenant{ - ID: tenantID, - CreatedAt: time.Now(), - })) - require.NoError(t, result.tenantStore.UpsertDestination(context.Background(), models.Destination{ - ID: destinationID, - TenantID: tenantID, - Type: "webhook", - Topics: []string{"*"}, - CreatedAt: time.Now(), - })) - - // Seed an attempt event - eventID := idgen.Event() - attemptID := idgen.Attempt() - eventTime := time.Now().Add(-1 * time.Hour).Truncate(time.Millisecond) - attemptTime := eventTime.Add(100 * time.Millisecond) - - event := testutil.EventFactory.AnyPointer( - testutil.EventFactory.WithID(eventID), - testutil.EventFactory.WithTenantID(tenantID), - testutil.EventFactory.WithDestinationID(destinationID), - testutil.EventFactory.WithTopic("order.created"), - testutil.EventFactory.WithTime(eventTime), - ) - - attempt := testutil.AttemptFactory.AnyPointer( - testutil.AttemptFactory.WithID(attemptID), - testutil.AttemptFactory.WithEventID(eventID), - testutil.AttemptFactory.WithDestinationID(destinationID), - testutil.AttemptFactory.WithStatus("failed"), - testutil.AttemptFactory.WithTime(attemptTime), - ) - - require.NoError(t, result.logStore.InsertMany(context.Background(), []*models.LogEntry{{Event: event, Attempt: attempt}})) - - t.Run("should retrieve attempt by ID", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/attempts/"+attemptID+"?tenant_id="+tenantID, nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - - assert.Equal(t, attemptID, response["id"]) - assert.Equal(t, "failed", response["status"]) - assert.Equal(t, eventID, response["event"]) // Not included - assert.Equal(t, destinationID, response["destination"]) - }) - - t.Run("should include event when include=event", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/attempts/"+attemptID+"?tenant_id="+tenantID+"&include=event", nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - - event := response["event"].(map[string]interface{}) - assert.Equal(t, eventID, event["id"]) - assert.Equal(t, "order.created", event["topic"]) - // data should not be present without include=event.data - assert.Nil(t, event["data"]) - }) - - t.Run("should include event.data when include=event.data", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/attempts/"+attemptID+"?tenant_id="+tenantID+"&include=event.data", nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - - event := response["event"].(map[string]interface{}) - assert.Equal(t, eventID, event["id"]) - assert.NotNil(t, event["data"]) // data should be present - }) - - t.Run("should return 404 for non-existent attempt", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/attempts/nonexistent", nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) - - t.Run("should return 404 for non-existent tenant", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/nonexistent/attempts/"+attemptID, nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) -} - -func TestRetrieveEvent(t *testing.T) { - t.Parallel() - - result := setupTestRouterFull(t, "", "") - - // Create a tenant - tenantID := idgen.String() - destinationID := idgen.Destination() - require.NoError(t, result.tenantStore.UpsertTenant(context.Background(), models.Tenant{ - ID: tenantID, - CreatedAt: time.Now(), - })) - require.NoError(t, result.tenantStore.UpsertDestination(context.Background(), models.Destination{ - ID: destinationID, - TenantID: tenantID, - Type: "webhook", - Topics: []string{"*"}, - CreatedAt: time.Now(), - })) - - // Seed an attempt event - eventID := idgen.Event() - attemptID := idgen.Attempt() - eventTime := time.Now().Add(-1 * time.Hour).Truncate(time.Millisecond) - attemptTime := eventTime.Add(100 * time.Millisecond) - - event := testutil.EventFactory.AnyPointer( - testutil.EventFactory.WithID(eventID), - testutil.EventFactory.WithTenantID(tenantID), - testutil.EventFactory.WithDestinationID(destinationID), - testutil.EventFactory.WithTopic("payment.processed"), - testutil.EventFactory.WithTime(eventTime), - testutil.EventFactory.WithData(map[string]interface{}{ - "amount": 100.50, - }), - testutil.EventFactory.WithMetadata(map[string]string{ - "source": "stripe", - }), - ) - - attempt := testutil.AttemptFactory.AnyPointer( - testutil.AttemptFactory.WithID(attemptID), - testutil.AttemptFactory.WithEventID(eventID), - testutil.AttemptFactory.WithDestinationID(destinationID), - testutil.AttemptFactory.WithStatus("success"), - testutil.AttemptFactory.WithTime(attemptTime), - ) - - require.NoError(t, result.logStore.InsertMany(context.Background(), []*models.LogEntry{{Event: event, Attempt: attempt}})) - - t.Run("should retrieve event by ID", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/events/"+eventID+"?tenant_id="+tenantID, nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - - assert.Equal(t, eventID, response["id"]) - assert.Equal(t, "payment.processed", response["topic"]) - assert.Equal(t, "stripe", response["metadata"].(map[string]interface{})["source"]) - assert.Equal(t, 100.50, response["data"].(map[string]interface{})["amount"]) - // tenant_id is not included in API response (tenant-scoped via URL) - assert.Nil(t, response["tenant_id"]) - }) - - t.Run("should return 404 for non-existent event", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/events/nonexistent", nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) - - t.Run("should return 404 for non-existent tenant", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/nonexistent/events/"+eventID, nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) -} - -func TestListEvents(t *testing.T) { - t.Parallel() - - result := setupTestRouterFull(t, "", "") - - // Create a tenant - tenantID := idgen.String() - destinationID := idgen.Destination() - require.NoError(t, result.tenantStore.UpsertTenant(context.Background(), models.Tenant{ - ID: tenantID, - CreatedAt: time.Now(), - })) - require.NoError(t, result.tenantStore.UpsertDestination(context.Background(), models.Destination{ - ID: destinationID, - TenantID: tenantID, - Type: "webhook", - Topics: []string{"*"}, - CreatedAt: time.Now(), - })) - - t.Run("should return empty list when no events", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/events?tenant_id="+tenantID, nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - - data := response["models"].([]interface{}) - assert.Len(t, data, 0) - }) - - t.Run("should list events", func(t *testing.T) { - // Seed attempt events - eventID := idgen.Event() - attemptID := idgen.Attempt() - eventTime := time.Now().Add(-1 * time.Hour).Truncate(time.Millisecond) - attemptTime := eventTime.Add(100 * time.Millisecond) - - event := testutil.EventFactory.AnyPointer( - testutil.EventFactory.WithID(eventID), - testutil.EventFactory.WithTenantID(tenantID), - testutil.EventFactory.WithDestinationID(destinationID), - testutil.EventFactory.WithTopic("user.created"), - testutil.EventFactory.WithTime(eventTime), - testutil.EventFactory.WithData(map[string]interface{}{ - "user_id": "123", - }), - ) - - attempt := testutil.AttemptFactory.AnyPointer( - testutil.AttemptFactory.WithID(attemptID), - testutil.AttemptFactory.WithEventID(eventID), - testutil.AttemptFactory.WithDestinationID(destinationID), - testutil.AttemptFactory.WithStatus("success"), - testutil.AttemptFactory.WithTime(attemptTime), - ) - - require.NoError(t, result.logStore.InsertMany(context.Background(), []*models.LogEntry{{Event: event, Attempt: attempt}})) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/events?tenant_id="+tenantID, nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - - data := response["models"].([]interface{}) - assert.Len(t, data, 1) - - firstEvent := data[0].(map[string]interface{}) - assert.Equal(t, eventID, firstEvent["id"]) - assert.Equal(t, "user.created", firstEvent["topic"]) - assert.NotNil(t, firstEvent["data"]) - }) - - t.Run("should filter by destination_id", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/events?tenant_id="+tenantID+"&destination_id="+destinationID, nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - - data := response["models"].([]interface{}) - assert.GreaterOrEqual(t, len(data), 1) - }) - - t.Run("should filter by non-existent destination_id", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/events?tenant_id="+tenantID+"&destination_id=nonexistent", nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - - data := response["models"].([]interface{}) - assert.Len(t, data, 0) - }) - - t.Run("should filter by topic", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/events?tenant_id="+tenantID+"&topic=user.created", nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - - data := response["models"].([]interface{}) - assert.GreaterOrEqual(t, len(data), 1) - for _, item := range data { - event := item.(map[string]interface{}) - assert.Equal(t, "user.created", event["topic"]) - } - }) - - t.Run("should return 404 for non-existent tenant", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/nonexistent/events", nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) - - t.Run("should return validation error for invalid time filter", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/events?tenant_id="+tenantID+"&time[gte]=invalid", nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusUnprocessableEntity, w.Code) - }) - - t.Run("should return validation error for invalid time lte filter", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/events?tenant_id="+tenantID+"&time[lte]=invalid", nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusUnprocessableEntity, w.Code) - }) - - t.Run("should return validation error for invalid dir", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/events?tenant_id="+tenantID+"&dir=invalid", nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusUnprocessableEntity, w.Code) - }) - - t.Run("should accept valid dir param", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/events?tenant_id="+tenantID+"&dir=asc", nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - }) - - t.Run("should cap limit at 1000", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/events?tenant_id="+tenantID+"&limit=5000", nil) - result.router.ServeHTTP(w, req) - - // Should succeed, limit is silently capped - assert.Equal(t, http.StatusOK, w.Code) - }) -} diff --git a/internal/apirouter/publish_handlers_test.go b/internal/apirouter/publish_handlers_test.go deleted file mode 100644 index fdc026f4..00000000 --- a/internal/apirouter/publish_handlers_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package apirouter_test - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/hookdeck/outpost/internal/idgen" - "github.com/hookdeck/outpost/internal/models" - "github.com/stretchr/testify/assert" -) - -func TestPublishHandlers(t *testing.T) { - t.Parallel() - - router, _, _ := setupTestRouter(t, "", "") - - t.Run("should ingest events", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - - testEvent := models.Event{ - ID: idgen.Event(), - TenantID: idgen.String(), - DestinationID: idgen.Destination(), - Topic: "user.created", - Time: time.Now(), - Metadata: map[string]string{"key": "value"}, - Data: map[string]interface{}{"key": "value"}, - } - testEventJSON, _ := json.Marshal(testEvent) - req, _ := http.NewRequest("POST", baseAPIPath+"/publish", strings.NewReader(string(testEventJSON))) - router.ServeHTTP(w, req) - - var response map[string]any - json.Unmarshal(w.Body.Bytes(), &response) - - assert.Equal(t, http.StatusAccepted, w.Code) - }) -} diff --git a/internal/apirouter/retry_handlers_test.go b/internal/apirouter/retry_handlers_test.go deleted file mode 100644 index beb6ceeb..00000000 --- a/internal/apirouter/retry_handlers_test.go +++ /dev/null @@ -1,239 +0,0 @@ -package apirouter_test - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/hookdeck/outpost/internal/idgen" - "github.com/hookdeck/outpost/internal/models" - "github.com/hookdeck/outpost/internal/util/testutil" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func retryBody(eventID, destinationID string) *bytes.Buffer { - body, _ := json.Marshal(map[string]string{ - "event_id": eventID, - "destination_id": destinationID, - }) - return bytes.NewBuffer(body) -} - -func TestRetry(t *testing.T) { - t.Parallel() - - result := setupTestRouterFull(t, "", "") - - // Create a tenant and destination - tenantID := idgen.String() - destinationID := idgen.Destination() - require.NoError(t, result.tenantStore.UpsertTenant(context.Background(), models.Tenant{ - ID: tenantID, - CreatedAt: time.Now(), - })) - require.NoError(t, result.tenantStore.UpsertDestination(context.Background(), models.Destination{ - ID: destinationID, - TenantID: tenantID, - Type: "webhook", - Topics: []string{"*"}, - CreatedAt: time.Now(), - })) - - // Seed an event - eventID := idgen.Event() - attemptID := idgen.Attempt() - eventTime := time.Now().Add(-1 * time.Hour).Truncate(time.Millisecond) - attemptTime := eventTime.Add(100 * time.Millisecond) - - event := testutil.EventFactory.AnyPointer( - testutil.EventFactory.WithID(eventID), - testutil.EventFactory.WithTenantID(tenantID), - testutil.EventFactory.WithDestinationID(destinationID), - testutil.EventFactory.WithTopic("order.created"), - testutil.EventFactory.WithTime(eventTime), - ) - - attempt := testutil.AttemptFactory.AnyPointer( - testutil.AttemptFactory.WithID(attemptID), - testutil.AttemptFactory.WithEventID(eventID), - testutil.AttemptFactory.WithDestinationID(destinationID), - testutil.AttemptFactory.WithStatus("failed"), - testutil.AttemptFactory.WithTime(attemptTime), - ) - - require.NoError(t, result.logStore.InsertMany(context.Background(), []*models.LogEntry{{Event: event, Attempt: attempt}})) - - t.Run("should retry successfully with full event data", func(t *testing.T) { - // Subscribe to deliveryMQ to capture published task - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - subscription, err := result.deliveryMQ.Subscribe(ctx) - require.NoError(t, err) - - // Trigger manual retry - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", baseAPIPath+"/retry", retryBody(eventID, destinationID)) - req.Header.Set("Content-Type", "application/json") - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusAccepted, w.Code) - - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - assert.Equal(t, true, response["success"]) - - // Verify published task has full event data - msg, err := subscription.Receive(ctx) - require.NoError(t, err) - - var task models.DeliveryTask - require.NoError(t, json.Unmarshal(msg.Body, &task)) - - assert.Equal(t, eventID, task.Event.ID) - assert.Equal(t, tenantID, task.Event.TenantID) - assert.Equal(t, destinationID, task.Event.DestinationID) - assert.Equal(t, "order.created", task.Event.Topic) - assert.False(t, task.Event.Time.IsZero(), "event time should be set") - assert.Equal(t, eventTime.UTC(), task.Event.Time.UTC()) - assert.Equal(t, event.Data, task.Event.Data, "event data should match original") - assert.True(t, task.Manual, "should be marked as manual retry") - - msg.Ack() - }) - - t.Run("should return 404 for non-existent event", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", baseAPIPath+"/retry", retryBody("nonexistent", destinationID)) - req.Header.Set("Content-Type", "application/json") - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) - - t.Run("should return 404 for non-existent destination", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", baseAPIPath+"/retry", retryBody(eventID, "nonexistent")) - req.Header.Set("Content-Type", "application/json") - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) - - t.Run("should return 400 when missing event_id", func(t *testing.T) { - body, _ := json.Marshal(map[string]string{"destination_id": destinationID}) - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", baseAPIPath+"/retry", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - }) - - t.Run("should return 400 when missing destination_id", func(t *testing.T) { - body, _ := json.Marshal(map[string]string{"event_id": eventID}) - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", baseAPIPath+"/retry", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - }) - - t.Run("should return 400 when destination is disabled", func(t *testing.T) { - // Create a new destination that's disabled - disabledDestinationID := idgen.Destination() - disabledAt := time.Now() - require.NoError(t, result.tenantStore.UpsertDestination(context.Background(), models.Destination{ - ID: disabledDestinationID, - TenantID: tenantID, - Type: "webhook", - Topics: []string{"*"}, - CreatedAt: time.Now(), - DisabledAt: &disabledAt, - })) - - // Create an event for the disabled destination - disabledEventID := idgen.Event() - disabledAttemptID := idgen.Attempt() - - disabledEvent := testutil.EventFactory.AnyPointer( - testutil.EventFactory.WithID(disabledEventID), - testutil.EventFactory.WithTenantID(tenantID), - testutil.EventFactory.WithDestinationID(disabledDestinationID), - testutil.EventFactory.WithTopic("order.created"), - testutil.EventFactory.WithTime(eventTime), - ) - - disabledAttempt := testutil.AttemptFactory.AnyPointer( - testutil.AttemptFactory.WithID(disabledAttemptID), - testutil.AttemptFactory.WithEventID(disabledEventID), - testutil.AttemptFactory.WithDestinationID(disabledDestinationID), - testutil.AttemptFactory.WithStatus("failed"), - testutil.AttemptFactory.WithTime(attemptTime), - ) - - require.NoError(t, result.logStore.InsertMany(context.Background(), []*models.LogEntry{{Event: disabledEvent, Attempt: disabledAttempt}})) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", baseAPIPath+"/retry", retryBody(disabledEventID, disabledDestinationID)) - req.Header.Set("Content-Type", "application/json") - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - assert.Equal(t, "Destination is disabled", response["message"]) - }) - - t.Run("should return 400 when destination does not match event", func(t *testing.T) { - // Create a destination that only matches "order.created" topic - mismatchDestinationID := idgen.Destination() - require.NoError(t, result.tenantStore.UpsertDestination(context.Background(), models.Destination{ - ID: mismatchDestinationID, - TenantID: tenantID, - Type: "webhook", - Topics: []string{"order.created"}, - CreatedAt: time.Now(), - })) - - // Create an event with a different topic - mismatchEventID := idgen.Event() - mismatchAttemptID := idgen.Attempt() - - mismatchEvent := testutil.EventFactory.AnyPointer( - testutil.EventFactory.WithID(mismatchEventID), - testutil.EventFactory.WithTenantID(tenantID), - testutil.EventFactory.WithDestinationID(mismatchDestinationID), - testutil.EventFactory.WithTopic("user.updated"), - testutil.EventFactory.WithTime(eventTime), - ) - - mismatchAttempt := testutil.AttemptFactory.AnyPointer( - testutil.AttemptFactory.WithID(mismatchAttemptID), - testutil.AttemptFactory.WithEventID(mismatchEventID), - testutil.AttemptFactory.WithDestinationID(mismatchDestinationID), - testutil.AttemptFactory.WithStatus("failed"), - testutil.AttemptFactory.WithTime(attemptTime), - ) - - require.NoError(t, result.logStore.InsertMany(context.Background(), []*models.LogEntry{{Event: mismatchEvent, Attempt: mismatchAttempt}})) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", baseAPIPath+"/retry", retryBody(mismatchEventID, mismatchDestinationID)) - req.Header.Set("Content-Type", "application/json") - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - assert.Equal(t, "destination does not match event", response["message"]) - }) -} diff --git a/internal/apirouter/router_test.go b/internal/apirouter/router_test.go deleted file mode 100644 index 1c80bb27..00000000 --- a/internal/apirouter/router_test.go +++ /dev/null @@ -1,393 +0,0 @@ -package apirouter_test - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/hookdeck/outpost/internal/clickhouse" - "github.com/hookdeck/outpost/internal/deliverymq" - "github.com/hookdeck/outpost/internal/eventtracer" - "github.com/hookdeck/outpost/internal/idempotence" - "github.com/hookdeck/outpost/internal/idgen" - "github.com/hookdeck/outpost/internal/logging" - "github.com/hookdeck/outpost/internal/logstore" - "github.com/hookdeck/outpost/internal/publishmq" - "github.com/hookdeck/outpost/internal/redis" - "github.com/hookdeck/outpost/internal/telemetry" - "github.com/hookdeck/outpost/internal/tenantstore" - - "github.com/hookdeck/outpost/internal/apirouter" - "github.com/hookdeck/outpost/internal/util/testutil" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const baseAPIPath = "/api/v1" - -type testRouterResult struct { - router http.Handler - logger *logging.Logger - redisClient redis.Client - tenantStore tenantstore.TenantStore - logStore logstore.LogStore - deliveryMQ *deliverymq.DeliveryMQ -} - -func setupTestRouter(t *testing.T, apiKey, jwtSecret string, funcs ...func(t *testing.T) clickhouse.DB) (http.Handler, *logging.Logger, redis.Client) { - result := setupTestRouterFull(t, apiKey, jwtSecret, funcs...) - return result.router, result.logger, result.redisClient -} - -func setupTestRouterFull(t *testing.T, apiKey, jwtSecret string, funcs ...func(t *testing.T) clickhouse.DB) testRouterResult { - gin.SetMode(gin.TestMode) - logger := testutil.CreateTestLogger(t) - redisClient := testutil.CreateTestRedisClient(t) - deliveryMQ := deliverymq.New() - deliveryMQ.Init(context.Background()) - eventTracer := eventtracer.NewNoopEventTracer() - tenantStore := setupTestTenantStore(t, redisClient) - logStore := setupTestLogStore(t, funcs...) - eventHandler := publishmq.NewEventHandler(logger, deliveryMQ, tenantStore, eventTracer, testutil.TestTopics, idempotence.New(redisClient, idempotence.WithSuccessfulTTL(24*time.Hour))) - router := apirouter.NewRouter( - apirouter.RouterConfig{ - ServiceName: "", - APIKey: apiKey, - JWTSecret: jwtSecret, - Topics: testutil.TestTopics, - Registry: testutil.Registry, - }, - logger, - redisClient, - deliveryMQ, - tenantStore, - logStore, - eventHandler, - &telemetry.NoopTelemetry{}, - ) - return testRouterResult{ - router: router, - logger: logger, - redisClient: redisClient, - tenantStore: tenantStore, - logStore: logStore, - deliveryMQ: deliveryMQ, - } -} - -func setupTestLogStore(t *testing.T, funcs ...func(t *testing.T) clickhouse.DB) logstore.LogStore { - var chDB clickhouse.DB - for _, f := range funcs { - chDB = f(t) - } - if chDB == nil { - return logstore.NewMemLogStore() - } - logStore, err := logstore.NewLogStore(context.Background(), logstore.DriverOpts{ - CH: chDB, - }) - require.NoError(t, err) - return logStore -} - -func setupTestTenantStore(_ *testing.T, redisClient redis.Client) tenantstore.TenantStore { - return tenantstore.New(tenantstore.Config{ - RedisClient: redisClient, - Secret: "secret", - AvailableTopics: testutil.TestTopics, - }) -} - -func TestRouterWithAPIKey(t *testing.T) { - t.Parallel() - - apiKey := "api_key" - jwtSecret := "jwt_secret" - router, _, _ := setupTestRouter(t, apiKey, jwtSecret) - - tenantID := "tenantID" - validToken, err := apirouter.JWT.New(jwtSecret, apirouter.JWTClaims{TenantID: tenantID}) - if err != nil { - t.Fatal(err) - } - - t.Run("should block unauthenticated request to admin routes", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", baseAPIPath+"/tenants/"+idgen.String(), nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusUnauthorized, w.Code) - }) - - t.Run("should block tenant-auth request to admin routes", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", baseAPIPath+"/tenants/"+idgen.String(), nil) - req.Header.Set("Authorization", "Bearer "+validToken) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusUnauthorized, w.Code) - }) - - t.Run("should allow admin request to admin routes", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", baseAPIPath+"/tenants/"+idgen.String(), nil) - req.Header.Set("Authorization", "Bearer "+apiKey) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusCreated, w.Code) - }) - - t.Run("should block unauthenticated request to tenant routes", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/tenantID", nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusUnauthorized, w.Code) - }) - - t.Run("should allow admin request to tenant routes", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/tenantIDnotfound", nil) - req.Header.Set("Authorization", "Bearer "+apiKey) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) - - t.Run("should return 401 for JWT tenant that doesn't exist in DB", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID, nil) - req.Header.Set("Authorization", "Bearer "+validToken) - router.ServeHTTP(w, req) - - // JWT references a tenant that doesn't exist — this is an auth failure, not a 404. - assert.Equal(t, http.StatusUnauthorized, w.Code) - }) - - t.Run("should block invalid tenant-auth request to tenant routes", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID, nil) - req.Header.Set("Authorization", "Bearer invalid") - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusUnauthorized, w.Code) - }) -} - -func TestRouterWithoutAPIKey(t *testing.T) { - t.Parallel() - - apiKey := "" - jwtSecret := "jwt_secret" - - router, _, _ := setupTestRouter(t, apiKey, jwtSecret) - - tenantID := "tenantID" - validToken, err := apirouter.JWT.New(jwtSecret, apirouter.JWTClaims{TenantID: tenantID}) - if err != nil { - t.Fatal(err) - } - - t.Run("should allow unauthenticated request to admin routes", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", baseAPIPath+"/tenants/"+idgen.String(), nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusCreated, w.Code) - }) - - t.Run("should allow tenant-auth request to admin routes", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", baseAPIPath+"/tenants/"+idgen.String(), nil) - req.Header.Set("Authorization", "Bearer "+validToken) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusCreated, w.Code) - }) - - t.Run("should allow admin request to admin routes", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", baseAPIPath+"/tenants/"+idgen.String(), nil) - req.Header.Set("Authorization", "Bearer "+apiKey) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusCreated, w.Code) - }) - - t.Run("should return 404 for JWT-only routes when apiKey is empty", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/destinations", nil) - req.Header.Set("Authorization", "Bearer "+validToken) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) - - t.Run("should return 404 for JWT-only routes with invalid token when apiKey is empty", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/destinations", nil) - req.Header.Set("Authorization", "Bearer invalid") - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) - - t.Run("should return 404 for JWT-only routes with invalid bearer format when apiKey is empty", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/destinations", nil) - req.Header.Set("Authorization", "NotBearer "+validToken) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) - - t.Run("should allow unauthenticated request to tenant routes", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/tenantID", nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) - - t.Run("should allow admin request to tenant routes", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/tenantIDnotfound", nil) - req.Header.Set("Authorization", "Bearer "+apiKey) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) - - t.Run("should allow tenant-auth request to tenant routes", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID, nil) - req.Header.Set("Authorization", "Bearer "+validToken) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) -} - -func TestTokenAndPortalRoutes(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - apiKey string - jwtSecret string - path string - }{ - { - name: "token route should return 404 when apiKey is empty", - apiKey: "", - jwtSecret: "secret", - path: "/tenants/tenant-id/token", - }, - { - name: "token route should return 404 when jwtSecret is empty", - apiKey: "key", - jwtSecret: "", - path: "/tenants/tenant-id/token", - }, - { - name: "portal route should return 404 when apiKey is empty", - apiKey: "", - jwtSecret: "secret", - path: "/tenants/tenant-id/portal", - }, - { - name: "portal route should return 404 when jwtSecret is empty", - apiKey: "key", - jwtSecret: "", - path: "/tenants/tenant-id/portal", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - router, _, _ := setupTestRouter(t, tt.apiKey, tt.jwtSecret) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+tt.path, nil) - if tt.apiKey != "" { - req.Header.Set("Authorization", "Bearer "+tt.apiKey) - } - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) - } -} - -func TestTenantsRoutePrefix(t *testing.T) { - t.Parallel() - - apiKey := "api_key" - router, _, _ := setupTestRouter(t, apiKey, "jwt_secret") - - t.Run("/tenants/ path should work for tenant upsert", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", baseAPIPath+"/tenants/"+idgen.String(), nil) - req.Header.Set("Authorization", "Bearer "+apiKey) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusCreated, w.Code) - }) - - t.Run("/tenants/ path should work for tenant GET", func(t *testing.T) { - t.Parallel() - - // First create a tenant - tenantID := idgen.String() - createReq, _ := http.NewRequest("PUT", baseAPIPath+"/tenants/"+tenantID, nil) - createReq.Header.Set("Authorization", "Bearer "+apiKey) - createW := httptest.NewRecorder() - router.ServeHTTP(createW, createReq) - require.Equal(t, http.StatusCreated, createW.Code) - - // GET via /tenants/ path - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID, nil) - req.Header.Set("Authorization", "Bearer "+apiKey) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - }) -} diff --git a/internal/apirouter/tenant_handlers_test.go b/internal/apirouter/tenant_handlers_test.go deleted file mode 100644 index 09288f60..00000000 --- a/internal/apirouter/tenant_handlers_test.go +++ /dev/null @@ -1,336 +0,0 @@ -package apirouter_test - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/hookdeck/outpost/internal/idgen" - "github.com/hookdeck/outpost/internal/models" - "github.com/stretchr/testify/assert" -) - -func TestDestinationUpsertHandler(t *testing.T) { - t.Parallel() - - router, _, redisClient := setupTestRouter(t, "", "") - tenantStore := setupTestTenantStore(t, redisClient) - - t.Run("should create when there's no existing tenant", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - - id := idgen.String() - req, _ := http.NewRequest("PUT", baseAPIPath+"/tenants/"+id, nil) - router.ServeHTTP(w, req) - - var response map[string]any - json.Unmarshal(w.Body.Bytes(), &response) - - assert.Equal(t, http.StatusCreated, w.Code) - assert.Equal(t, id, response["id"]) - assert.NotEqual(t, "", response["created_at"]) - assert.NotEqual(t, "", response["updated_at"]) - assert.Equal(t, response["created_at"], response["updated_at"]) - }) - - t.Run("should return tenant when there's already one", func(t *testing.T) { - t.Parallel() - - // Setup - existingResource := models.Tenant{ - ID: idgen.String(), - CreatedAt: time.Now(), - } - tenantStore.UpsertTenant(context.Background(), existingResource) - - // Request - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", baseAPIPath+"/tenants/"+existingResource.ID, nil) - router.ServeHTTP(w, req) - var response map[string]any - json.Unmarshal(w.Body.Bytes(), &response) - - // Test - assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, existingResource.ID, response["id"]) - createdAt, err := time.Parse(time.RFC3339Nano, response["created_at"].(string)) - if err != nil { - t.Fatal(err) - } - // Compare at second precision since Redis stores Unix timestamps - assert.Equal(t, existingResource.CreatedAt.Unix(), createdAt.Unix()) - - // Cleanup - tenantStore.DeleteTenant(context.Background(), existingResource.ID) - }) -} - -func TestTenantRetrieveHandler(t *testing.T) { - t.Parallel() - - router, _, redisClient := setupTestRouter(t, "", "") - tenantStore := setupTestTenantStore(t, redisClient) - - t.Run("should return 404 when there's no tenant", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/invalid_id", nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) - - t.Run("should retrieve tenant", func(t *testing.T) { - t.Parallel() - - // Setup - existingResource := models.Tenant{ - ID: idgen.String(), - CreatedAt: time.Now(), - } - tenantStore.UpsertTenant(context.Background(), existingResource) - - // Request - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+existingResource.ID, nil) - router.ServeHTTP(w, req) - var response map[string]any - json.Unmarshal(w.Body.Bytes(), &response) - - // Test - assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, existingResource.ID, response["id"]) - createdAt, err := time.Parse(time.RFC3339Nano, response["created_at"].(string)) - if err != nil { - t.Fatal(err) - } - // Compare at second precision since Redis stores Unix timestamps - assert.Equal(t, existingResource.CreatedAt.Unix(), createdAt.Unix()) - - // Cleanup - tenantStore.DeleteTenant(context.Background(), existingResource.ID) - }) -} - -func TestTenantDeleteHandler(t *testing.T) { - t.Parallel() - - router, _, redisClient := setupTestRouter(t, "", "") - tenantStore := setupTestTenantStore(t, redisClient) - - t.Run("should return 404 when there's no tenant", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - req, _ := http.NewRequest("DELETE", baseAPIPath+"/tenants/invalid_id", nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) - - t.Run("should delete tenant", func(t *testing.T) { - t.Parallel() - - // Setup - existingResource := models.Tenant{ - ID: idgen.String(), - CreatedAt: time.Now(), - } - tenantStore.UpsertTenant(context.Background(), existingResource) - - // Request - w := httptest.NewRecorder() - req, _ := http.NewRequest("DELETE", baseAPIPath+"/tenants/"+existingResource.ID, nil) - router.ServeHTTP(w, req) - var response map[string]any - json.Unmarshal(w.Body.Bytes(), &response) - - // Test - assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, true, response["success"]) - }) - - t.Run("should delete tenant and associated destinations", func(t *testing.T) { - t.Parallel() - - // Setup - existingResource := models.Tenant{ - ID: idgen.String(), - CreatedAt: time.Now(), - } - tenantStore.UpsertTenant(context.Background(), existingResource) - inputDestination := models.Destination{ - Type: "webhook", - Topics: []string{"user.created", "user.updated"}, - DisabledAt: nil, - TenantID: existingResource.ID, - } - ids := make([]string, 5) - for i := 0; i < 5; i++ { - ids[i] = idgen.String() - inputDestination.ID = ids[i] - inputDestination.CreatedAt = time.Now() - tenantStore.UpsertDestination(context.Background(), inputDestination) - } - - // Request - w := httptest.NewRecorder() - req, _ := http.NewRequest("DELETE", baseAPIPath+"/tenants/"+existingResource.ID, nil) - router.ServeHTTP(w, req) - var response map[string]any - json.Unmarshal(w.Body.Bytes(), &response) - - // Test - assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, true, response["success"]) - - destinations, err := tenantStore.ListDestinationByTenant(context.Background(), existingResource.ID) - assert.Nil(t, err) - assert.Equal(t, 0, len(destinations)) - }) -} - -func TestTenantRetrieveTokenHandler(t *testing.T) { - t.Parallel() - - apiKey := "api_key" - jwtSecret := "jwt_secret" - router, _, redisClient := setupTestRouter(t, apiKey, jwtSecret) - tenantStore := setupTestTenantStore(t, redisClient) - - t.Run("should return token and tenant_id", func(t *testing.T) { - t.Parallel() - - // Setup - existingResource := models.Tenant{ - ID: idgen.String(), - CreatedAt: time.Now(), - } - tenantStore.UpsertTenant(context.Background(), existingResource) - - // Request - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+existingResource.ID+"/token", nil) - req.Header.Set("Authorization", "Bearer "+apiKey) - router.ServeHTTP(w, req) - var response map[string]any - json.Unmarshal(w.Body.Bytes(), &response) - - // Test - assert.Equal(t, http.StatusOK, w.Code) - assert.NotEmpty(t, response["token"]) - assert.Equal(t, existingResource.ID, response["tenant_id"]) - - // Cleanup - tenantStore.DeleteTenant(context.Background(), existingResource.ID) - }) -} - -func TestTenantRetrievePortalHandler(t *testing.T) { - t.Parallel() - - apiKey := "api_key" - jwtSecret := "jwt_secret" - router, _, redisClient := setupTestRouter(t, apiKey, jwtSecret) - tenantStore := setupTestTenantStore(t, redisClient) - - t.Run("should return redirect_url with token and tenant_id in body", func(t *testing.T) { - t.Parallel() - - // Setup - existingResource := models.Tenant{ - ID: idgen.String(), - CreatedAt: time.Now(), - } - tenantStore.UpsertTenant(context.Background(), existingResource) - - // Request - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+existingResource.ID+"/portal", nil) - req.Header.Set("Authorization", "Bearer "+apiKey) - router.ServeHTTP(w, req) - var response map[string]any - json.Unmarshal(w.Body.Bytes(), &response) - - // Test - assert.Equal(t, http.StatusOK, w.Code) - assert.NotEmpty(t, response["redirect_url"]) - assert.Contains(t, response["redirect_url"], "token=") - assert.Equal(t, existingResource.ID, response["tenant_id"]) - - // Cleanup - tenantStore.DeleteTenant(context.Background(), existingResource.ID) - }) - - t.Run("should include theme in redirect_url when provided", func(t *testing.T) { - t.Parallel() - - // Setup - existingResource := models.Tenant{ - ID: idgen.String(), - CreatedAt: time.Now(), - } - tenantStore.UpsertTenant(context.Background(), existingResource) - - // Request - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+existingResource.ID+"/portal?theme=dark", nil) - req.Header.Set("Authorization", "Bearer "+apiKey) - router.ServeHTTP(w, req) - var response map[string]any - json.Unmarshal(w.Body.Bytes(), &response) - - // Test - assert.Equal(t, http.StatusOK, w.Code) - assert.Contains(t, response["redirect_url"], "token=") - assert.Contains(t, response["redirect_url"], "theme=dark") - assert.Equal(t, existingResource.ID, response["tenant_id"]) - - // Cleanup - tenantStore.DeleteTenant(context.Background(), existingResource.ID) - }) -} - -func TestTenantListHandler(t *testing.T) { - t.Parallel() - - router, _, redisClient := setupTestRouter(t, "", "") - _ = setupTestTenantStore(t, redisClient) - - // Note: These tests use miniredis which doesn't support RediSearch. - // The ListTenant feature requires RediSearch, so we expect 501 Not Implemented. - - t.Run("should return 501 when RediSearch is not available", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants", nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotImplemented, w.Code) - - var response map[string]any - json.Unmarshal(w.Body.Bytes(), &response) - assert.Contains(t, response["message"], "not enabled") - }) - - t.Run("should return 400 for invalid limit", func(t *testing.T) { - t.Parallel() - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants?limit=notanumber", nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - - var response map[string]any - json.Unmarshal(w.Body.Bytes(), &response) - assert.Contains(t, response["message"], "invalid limit") - }) -} From c23a5829d40a507ff424f25608fa97fa9f445df8 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sat, 31 Jan 2026 19:57:00 +0700 Subject: [PATCH 10/34] refactor: simplify router deps --- internal/apirouter/publish_handlers.go | 9 +++- internal/apirouter/retry_handlers.go | 26 ++++++---- internal/apirouter/router.go | 69 ++++++++++++++++++-------- internal/services/builder.go | 15 +++--- 4 files changed, 77 insertions(+), 42 deletions(-) diff --git a/internal/apirouter/publish_handlers.go b/internal/apirouter/publish_handlers.go index 3c7ce742..3a84c3d3 100644 --- a/internal/apirouter/publish_handlers.go +++ b/internal/apirouter/publish_handlers.go @@ -1,6 +1,7 @@ package apirouter import ( + "context" "errors" "net/http" "time" @@ -13,14 +14,18 @@ import ( "github.com/hookdeck/outpost/internal/publishmq" ) +type eventHandler interface { + Handle(ctx context.Context, event *models.Event) (*publishmq.HandleResult, error) +} + type PublishHandlers struct { logger *logging.Logger - eventHandler publishmq.EventHandler + eventHandler eventHandler } func NewPublishHandlers( logger *logging.Logger, - eventHandler publishmq.EventHandler, + eventHandler eventHandler, ) *PublishHandlers { return &PublishHandlers{ logger: logger, diff --git a/internal/apirouter/retry_handlers.go b/internal/apirouter/retry_handlers.go index c5789e33..29f44625 100644 --- a/internal/apirouter/retry_handlers.go +++ b/internal/apirouter/retry_handlers.go @@ -1,10 +1,10 @@ package apirouter import ( + "context" "net/http" "github.com/gin-gonic/gin" - "github.com/hookdeck/outpost/internal/deliverymq" "github.com/hookdeck/outpost/internal/logging" "github.com/hookdeck/outpost/internal/logstore" "github.com/hookdeck/outpost/internal/models" @@ -12,24 +12,28 @@ import ( "go.uber.org/zap" ) +type deliveryPublisher interface { + Publish(ctx context.Context, task models.DeliveryTask) error +} + type RetryHandlers struct { - logger *logging.Logger - tenantStore tenantstore.TenantStore - logStore logstore.LogStore - deliveryMQ *deliverymq.DeliveryMQ + logger *logging.Logger + tenantStore tenantstore.TenantStore + logStore logstore.LogStore + deliveryPublisher deliveryPublisher } func NewRetryHandlers( logger *logging.Logger, tenantStore tenantstore.TenantStore, logStore logstore.LogStore, - deliveryMQ *deliverymq.DeliveryMQ, + deliveryPublisher deliveryPublisher, ) *RetryHandlers { return &RetryHandlers{ - logger: logger, - tenantStore: tenantStore, - logStore: logStore, - deliveryMQ: deliveryMQ, + logger: logger, + tenantStore: tenantStore, + logStore: logStore, + deliveryPublisher: deliveryPublisher, } } @@ -104,7 +108,7 @@ func (h *RetryHandlers) Retry(c *gin.Context) { // 3. Create and publish manual delivery task task := models.NewManualDeliveryTask(*event, req.DestinationID) - if err := h.deliveryMQ.Publish(c.Request.Context(), task); err != nil { + if err := h.deliveryPublisher.Publish(c.Request.Context(), task); err != nil { AbortWithError(c, http.StatusInternalServerError, NewErrInternalServer(err)) return } diff --git a/internal/apirouter/router.go b/internal/apirouter/router.go index e81728dd..79db3034 100644 --- a/internal/apirouter/router.go +++ b/internal/apirouter/router.go @@ -6,16 +6,15 @@ import ( "reflect" "strings" + "fmt" + "github.com/gin-gonic/gin" "github.com/gin-gonic/gin/binding" "github.com/go-playground/validator/v10" - "github.com/hookdeck/outpost/internal/deliverymq" "github.com/hookdeck/outpost/internal/destregistry" "github.com/hookdeck/outpost/internal/logging" "github.com/hookdeck/outpost/internal/logstore" "github.com/hookdeck/outpost/internal/portal" - "github.com/hookdeck/outpost/internal/publishmq" - "github.com/hookdeck/outpost/internal/redis" "github.com/hookdeck/outpost/internal/telemetry" "github.com/hookdeck/outpost/internal/tenantstore" "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" @@ -49,6 +48,37 @@ type RouterConfig struct { GinMode string } +type RouterDeps struct { + TenantStore tenantstore.TenantStore + LogStore logstore.LogStore + Logger *logging.Logger + DeliveryPublisher deliveryPublisher + EventHandler eventHandler + Telemetry telemetry.Telemetry +} + +func (d RouterDeps) validate() error { + if d.TenantStore == nil { + return fmt.Errorf("apirouter: TenantStore is required") + } + if d.LogStore == nil { + return fmt.Errorf("apirouter: LogStore is required") + } + if d.Logger == nil { + return fmt.Errorf("apirouter: Logger is required") + } + if d.DeliveryPublisher == nil { + return fmt.Errorf("apirouter: DeliveryPublisher is required") + } + if d.EventHandler == nil { + return fmt.Errorf("apirouter: EventHandler is required") + } + if d.Telemetry == nil { + return fmt.Errorf("apirouter: Telemetry is required") + } + return nil +} + // registerRoutes registers routes to the given router based on route definitions and config func registerRoutes(router *gin.RouterGroup, cfg RouterConfig, tenantRetriever TenantRetriever, routes []RouteDefinition) { for _, route := range routes { @@ -86,16 +116,11 @@ func buildMiddlewareChain(cfg RouterConfig, tenantRetriever TenantRetriever, def return chain } -func NewRouter( - cfg RouterConfig, - logger *logging.Logger, - redisClient redis.Cmdable, - deliveryMQ *deliverymq.DeliveryMQ, - tenantStore tenantstore.TenantStore, - logStore logstore.LogStore, - publishmqEventHandler publishmq.EventHandler, - telemetry telemetry.Telemetry, -) http.Handler { +func NewRouter(cfg RouterConfig, deps RouterDeps) http.Handler { + if err := deps.validate(); err != nil { + panic(err) + } + // Only set mode from config if we're not in test mode if gin.Mode() != gin.TestMode { gin.SetMode(cfg.GinMode) @@ -104,13 +129,13 @@ func NewRouter( r := gin.New() // Core middlewares r.Use(gin.Recovery()) - r.Use(telemetry.MakeSentryHandler()) + r.Use(deps.Telemetry.MakeSentryHandler()) r.Use(otelgin.Middleware(cfg.ServiceName)) r.Use(MetricsMiddleware()) // Create sanitizer for secure request body logging on 5xx errors sanitizer := NewRequestBodySanitizer(cfg.Registry) - r.Use(LoggerMiddlewareWithSanitizer(logger, sanitizer)) + r.Use(LoggerMiddlewareWithSanitizer(deps.Logger, sanitizer)) r.Use(LatencyMiddleware()) // LatencyMiddleware must be after Metrics & Logger to fully capture latency first @@ -131,12 +156,12 @@ func NewRouter( apiRouter := r.Group("/api/v1") - tenantHandlers := NewTenantHandlers(logger, telemetry, cfg.JWTSecret, cfg.DeploymentID, tenantStore) - destinationHandlers := NewDestinationHandlers(logger, telemetry, tenantStore, cfg.Topics, cfg.Registry) - publishHandlers := NewPublishHandlers(logger, publishmqEventHandler) - logHandlers := NewLogHandlers(logger, logStore) - retryHandlers := NewRetryHandlers(logger, tenantStore, logStore, deliveryMQ) - topicHandlers := NewTopicHandlers(logger, cfg.Topics) + tenantHandlers := NewTenantHandlers(deps.Logger, deps.Telemetry, cfg.JWTSecret, cfg.DeploymentID, deps.TenantStore) + destinationHandlers := NewDestinationHandlers(deps.Logger, deps.Telemetry, deps.TenantStore, cfg.Topics, cfg.Registry) + publishHandlers := NewPublishHandlers(deps.Logger, deps.EventHandler) + logHandlers := NewLogHandlers(deps.Logger, deps.LogStore) + retryHandlers := NewRetryHandlers(deps.Logger, deps.TenantStore, deps.LogStore, deps.DeliveryPublisher) + topicHandlers := NewTopicHandlers(deps.Logger, cfg.Topics) routes := []RouteDefinition{ // Schemas & Topics @@ -176,7 +201,7 @@ func NewRouter( {Method: http.MethodGet, Path: "/attempts/:attemptID", Handler: logHandlers.RetrieveAttempt, AuthMode: AuthAuthenticated}, } - registerRoutes(apiRouter, cfg, tenantStore, routes) + registerRoutes(apiRouter, cfg, deps.TenantStore, routes) // Register dev routes if gin.Mode() == gin.DebugMode { diff --git a/internal/services/builder.go b/internal/services/builder.go index a8be0188..b3c0e71b 100644 --- a/internal/services/builder.go +++ b/internal/services/builder.go @@ -201,13 +201,14 @@ func (b *ServiceBuilder) BuildAPIWorkers(baseRouter *gin.Engine) error { PortalConfig: b.cfg.GetPortalConfig(), GinMode: b.cfg.GinMode, }, - b.logger, - svc.redisClient, - svc.deliveryMQ, - svc.tenantStore, - svc.logStore, - eventHandler, - b.telemetry, + apirouter.RouterDeps{ + TenantStore: svc.tenantStore, + LogStore: svc.logStore, + Logger: b.logger, + DeliveryPublisher: svc.deliveryMQ, + EventHandler: eventHandler, + Telemetry: b.telemetry, + }, ) // Mount API handler onto base router (everything except /healthz goes to apiHandler) From 60a5ccb77285d5d1b4b92ed48b16edef2566a50b Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sat, 31 Jan 2026 20:09:06 +0700 Subject: [PATCH 11/34] test: apirouter test setup --- go.mod | 2 +- internal/apirouter/router_test.go | 188 ++++++++++++++++++++++ internal/apirouter/topic_handlers_test.go | 50 ++++++ 3 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 internal/apirouter/router_test.go create mode 100644 internal/apirouter/topic_handlers_test.go diff --git a/go.mod b/go.mod index 48572390..776f1625 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/hookdeck/outpost -go 1.23.0 +go 1.24.0 require ( cloud.google.com/go/pubsub v1.41.0 diff --git a/internal/apirouter/router_test.go b/internal/apirouter/router_test.go new file mode 100644 index 00000000..892e5e04 --- /dev/null +++ b/internal/apirouter/router_test.go @@ -0,0 +1,188 @@ +package apirouter_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/hookdeck/outpost/internal/apirouter" + "github.com/hookdeck/outpost/internal/destregistry" + "github.com/hookdeck/outpost/internal/destregistry/metadata" + "github.com/hookdeck/outpost/internal/logging" + "github.com/hookdeck/outpost/internal/logstore" + "github.com/hookdeck/outpost/internal/models" + "github.com/hookdeck/outpost/internal/portal" + "github.com/hookdeck/outpost/internal/publishmq" + "github.com/hookdeck/outpost/internal/telemetry" + "github.com/hookdeck/outpost/internal/tenantstore" + "github.com/uptrace/opentelemetry-go-extra/otelzap" + "go.uber.org/zap" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +const ( + testAPIKey = "test-api-key" + testJWTSecret = "test-jwt-secret" +) + +// --------------------------------------------------------------------------- +// apiTest harness +// --------------------------------------------------------------------------- + +type apiTest struct { + t *testing.T + router http.Handler + tenantStore tenantstore.TenantStore + logStore logstore.LogStore + deliveryPub *mockDeliveryPublisher + eventHandler *mockEventHandler +} + +func newAPITest(t *testing.T) *apiTest { + t.Helper() + + logger := &logging.Logger{Logger: otelzap.New(zap.NewNop())} + ts := tenantstore.NewMemTenantStore() + ls := logstore.NewMemLogStore() + dp := &mockDeliveryPublisher{} + eh := &mockEventHandler{} + + router := apirouter.NewRouter( + apirouter.RouterConfig{ + ServiceName: "test", + APIKey: testAPIKey, + JWTSecret: testJWTSecret, + Topics: []string{"user.created", "order.completed"}, + Registry: &stubRegistry{}, + PortalConfig: portal.PortalConfig{}, + }, + apirouter.RouterDeps{ + TenantStore: ts, + LogStore: ls, + Logger: logger, + DeliveryPublisher: dp, + EventHandler: eh, + Telemetry: &telemetry.NoopTelemetry{}, + }, + ) + + return &apiTest{ + t: t, + router: router, + tenantStore: ts, + logStore: ls, + deliveryPub: dp, + eventHandler: eh, + } +} + +// do executes a request and returns the response recorder. +func (a *apiTest) do(req *http.Request) *httptest.ResponseRecorder { + a.t.Helper() + w := httptest.NewRecorder() + a.router.ServeHTTP(w, req) + return w +} + +// doJSON builds a JSON request with the given method/path/body and executes it. +// body may be nil for requests with no body. +func (a *apiTest) doJSON(method, path string, body any) *httptest.ResponseRecorder { + a.t.Helper() + var reader io.Reader + if body != nil { + bs, err := json.Marshal(body) + if err != nil { + a.t.Fatal(err) + } + reader = strings.NewReader(string(bs)) + } + req := httptest.NewRequest(method, path, reader) + req.Header.Set("Content-Type", "application/json") + return a.do(req) +} + +// withAPIKey adds the API key auth header to the request. +func (a *apiTest) withAPIKey(req *http.Request) *http.Request { + req.Header.Set("Authorization", "Bearer "+testAPIKey) + return req +} + +// withJWT adds a JWT auth header for the given tenant. +func (a *apiTest) withJWT(req *http.Request, tenantID string) *http.Request { + a.t.Helper() + token, err := apirouter.JWT.New(testJWTSecret, apirouter.JWTClaims{TenantID: tenantID}) + if err != nil { + a.t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+token) + return req +} + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +// mockDeliveryPublisher records Publish calls. +type mockDeliveryPublisher struct { + calls []models.DeliveryTask +} + +func (m *mockDeliveryPublisher) Publish(_ context.Context, task models.DeliveryTask) error { + m.calls = append(m.calls, task) + return nil +} + +// mockEventHandler records Handle calls with configurable return values. +type mockEventHandler struct { + calls []*models.Event + result *publishmq.HandleResult + err error +} + +func (m *mockEventHandler) Handle(_ context.Context, event *models.Event) (*publishmq.HandleResult, error) { + m.calls = append(m.calls, event) + if m.err != nil { + return nil, m.err + } + if m.result != nil { + return m.result, nil + } + return &publishmq.HandleResult{EventID: event.ID}, nil +} + +// stubRegistry is a minimal destregistry.Registry for test setup. +// Most methods are unused — only the metadata-related ones matter for sanitizer init. +type stubRegistry struct{} + +func (r *stubRegistry) ValidateDestination(context.Context, *models.Destination) error { + return nil +} +func (r *stubRegistry) PublishEvent(context.Context, *models.Destination, *models.Event) (*models.Attempt, error) { + return nil, nil +} +func (r *stubRegistry) DisplayDestination(dest *models.Destination) (*destregistry.DestinationDisplay, error) { + return &destregistry.DestinationDisplay{Destination: dest}, nil +} +func (r *stubRegistry) PreprocessDestination(*models.Destination, *models.Destination, *destregistry.PreprocessDestinationOpts) error { + return nil +} +func (r *stubRegistry) RegisterProvider(string, destregistry.Provider) error { return nil } +func (r *stubRegistry) ResolveProvider(*models.Destination) (destregistry.Provider, error) { + return nil, nil +} +func (r *stubRegistry) ResolvePublisher(context.Context, *models.Destination) (destregistry.Publisher, error) { + return nil, nil +} +func (r *stubRegistry) MetadataLoader() metadata.MetadataLoader { return nil } +func (r *stubRegistry) RetrieveProviderMetadata(string) (*metadata.ProviderMetadata, error) { + return nil, nil +} +func (r *stubRegistry) ListProviderMetadata() []*metadata.ProviderMetadata { return nil } diff --git a/internal/apirouter/topic_handlers_test.go b/internal/apirouter/topic_handlers_test.go new file mode 100644 index 00000000..3f659549 --- /dev/null +++ b/internal/apirouter/topic_handlers_test.go @@ -0,0 +1,50 @@ +package apirouter_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/hookdeck/outpost/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPI_Topics(t *testing.T) { + t.Run("with API key returns topics", func(t *testing.T) { + h := newAPITest(t) + req := httptest.NewRequest(http.MethodGet, "/api/v1/topics", nil) + resp := h.do(h.withAPIKey(req)) + + assert.Equal(t, http.StatusOK, resp.Code) + + var topics []string + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &topics)) + assert.Equal(t, []string{"user.created", "order.completed"}, topics) + }) + + t.Run("with JWT returns topics", func(t *testing.T) { + h := newAPITest(t) + + // JWT auth middleware resolves the tenant, so it must exist + h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/topics", nil) + resp := h.do(h.withJWT(req, "t1")) + + assert.Equal(t, http.StatusOK, resp.Code) + + var topics []string + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &topics)) + assert.Equal(t, []string{"user.created", "order.completed"}, topics) + }) + + t.Run("without auth returns 401", func(t *testing.T) { + h := newAPITest(t) + req := httptest.NewRequest(http.MethodGet, "/api/v1/topics", nil) + resp := h.do(req) + + assert.Equal(t, http.StatusUnauthorized, resp.Code) + }) +} From 935482cc582dfbb63f489fbc5649951c1085eb54 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sat, 31 Jan 2026 20:41:02 +0700 Subject: [PATCH 12/34] test: tenant handlers --- internal/apirouter/auth_middleware.go | 4 +- internal/apirouter/router.go | 2 +- internal/apirouter/router_test.go | 6 +- internal/apirouter/tenant_handlers_test.go | 194 +++++++++++++++++++++ internal/apirouter/topic_handlers_test.go | 6 +- 5 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 internal/apirouter/tenant_handlers_test.go diff --git a/internal/apirouter/auth_middleware.go b/internal/apirouter/auth_middleware.go index 8f21c9b1..ed8403ab 100644 --- a/internal/apirouter/auth_middleware.go +++ b/internal/apirouter/auth_middleware.go @@ -105,7 +105,7 @@ func AuthenticatedMiddleware(apiKey string, jwtKey string) gin.HandlerFunc { // If tenantID param exists, verify it matches token if paramTenantID := c.Param("tenantID"); paramTenantID != "" && paramTenantID != claims.TenantID { - c.AbortWithStatus(http.StatusUnauthorized) + c.AbortWithStatus(http.StatusForbidden) return } @@ -236,7 +236,7 @@ func TenantJWTAuthMiddleware(apiKey string, jwtKey string) gin.HandlerFunc { // If tenantID param exists, verify it matches token if paramTenantID := c.Param("tenantID"); paramTenantID != "" && paramTenantID != claims.TenantID { - c.AbortWithStatus(http.StatusUnauthorized) + c.AbortWithStatus(http.StatusForbidden) return } diff --git a/internal/apirouter/router.go b/internal/apirouter/router.go index 79db3034..d263eb17 100644 --- a/internal/apirouter/router.go +++ b/internal/apirouter/router.go @@ -175,7 +175,7 @@ func NewRouter(cfg RouterConfig, deps RouterDeps) http.Handler { // Tenants {Method: http.MethodGet, Path: "/tenants", Handler: tenantHandlers.List, AuthMode: AuthAuthenticated}, - {Method: http.MethodPut, Path: "/tenants/:tenantID", Handler: tenantHandlers.Upsert, AuthMode: AuthAdmin}, + {Method: http.MethodPut, Path: "/tenants/:tenantID", Handler: tenantHandlers.Upsert, AuthMode: AuthAuthenticated}, {Method: http.MethodGet, Path: "/tenants/:tenantID", Handler: tenantHandlers.Retrieve, AuthMode: AuthAuthenticated, RequireTenant: true}, {Method: http.MethodDelete, Path: "/tenants/:tenantID", Handler: tenantHandlers.Delete, AuthMode: AuthAuthenticated, RequireTenant: true}, {Method: http.MethodGet, Path: "/tenants/:tenantID/token", Handler: tenantHandlers.RetrieveToken, AuthMode: AuthAdmin, RequireTenant: true}, diff --git a/internal/apirouter/router_test.go b/internal/apirouter/router_test.go index 892e5e04..7144ccb1 100644 --- a/internal/apirouter/router_test.go +++ b/internal/apirouter/router_test.go @@ -92,9 +92,9 @@ func (a *apiTest) do(req *http.Request) *httptest.ResponseRecorder { return w } -// doJSON builds a JSON request with the given method/path/body and executes it. +// jsonReq builds an *http.Request with a JSON body and Content-Type header. // body may be nil for requests with no body. -func (a *apiTest) doJSON(method, path string, body any) *httptest.ResponseRecorder { +func (a *apiTest) jsonReq(method, path string, body any) *http.Request { a.t.Helper() var reader io.Reader if body != nil { @@ -106,7 +106,7 @@ func (a *apiTest) doJSON(method, path string, body any) *httptest.ResponseRecord } req := httptest.NewRequest(method, path, reader) req.Header.Set("Content-Type", "application/json") - return a.do(req) + return req } // withAPIKey adds the API key auth header to the request. diff --git a/internal/apirouter/tenant_handlers_test.go b/internal/apirouter/tenant_handlers_test.go new file mode 100644 index 00000000..83d83b5b --- /dev/null +++ b/internal/apirouter/tenant_handlers_test.go @@ -0,0 +1,194 @@ +package apirouter_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/hookdeck/outpost/internal/models" + "github.com/hookdeck/outpost/internal/tenantstore" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPI_Tenants(t *testing.T) { + t.Run("Upsert", func(t *testing.T) { + t.Run("api key creates tenant", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodPut, "/api/v1/tenants/t1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusCreated, resp.Code) + + // Verify tenant exists in store + tenant, err := h.tenantStore.RetrieveTenant(t.Context(), "t1") + require.NoError(t, err) + assert.Equal(t, "t1", tenant.ID) + }) + + t.Run("api key updates metadata", func(t *testing.T) { + h := newAPITest(t) + + // Create tenant first + h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) + + // Update with metadata + req := h.jsonReq(http.MethodPut, "/api/v1/tenants/t1", map[string]any{ + "metadata": map[string]string{"env": "prod"}, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + // Verify metadata in store + tenant, err := h.tenantStore.RetrieveTenant(t.Context(), "t1") + require.NoError(t, err) + assert.Equal(t, models.Metadata{"env": "prod"}, tenant.Metadata) + }) + + t.Run("jwt updates own tenant", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) + + req := h.jsonReq(http.MethodPut, "/api/v1/tenants/t1", map[string]any{ + "metadata": map[string]string{"role": "owner"}, + }) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusOK, resp.Code) + + tenant, err := h.tenantStore.RetrieveTenant(t.Context(), "t1") + require.NoError(t, err) + assert.Equal(t, models.Metadata{"role": "owner"}, tenant.Metadata) + }) + }) + + t.Run("Retrieve", func(t *testing.T) { + t.Run("api key returns tenant", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var tenant models.Tenant + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &tenant)) + assert.Equal(t, "t1", tenant.ID) + }) + + t.Run("jwt returns own tenant", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusOK, resp.Code) + + var tenant models.Tenant + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &tenant)) + assert.Equal(t, "t1", tenant.ID) + }) + }) + + t.Run("List", func(t *testing.T) { + t.Run("api key returns all tenants", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) + h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t2"}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result tenantstore.TenantPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Equal(t, 2, result.Count) + assert.Len(t, result.Models, 2) + }) + + t.Run("jwt returns only own tenant", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) + h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t2"}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusOK, resp.Code) + + var result tenantstore.TenantPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Equal(t, 1, result.Count) + assert.Len(t, result.Models, 1) + assert.Equal(t, "t1", result.Models[0].ID) + }) + }) + + t.Run("Delete", func(t *testing.T) { + t.Run("api key deletes tenant", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/tenants/t1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + // Subsequent GET returns 404 + req = httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1", nil) + resp = h.do(h.withAPIKey(req)) + assert.Equal(t, http.StatusNotFound, resp.Code) + }) + + t.Run("jwt deletes own tenant", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/tenants/t1", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusOK, resp.Code) + + // Verify deleted in store + _, err := h.tenantStore.RetrieveTenant(t.Context(), "t1") + assert.ErrorIs(t, err, tenantstore.ErrTenantDeleted) + }) + }) + + t.Run("jwt other tenant returns 403", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) + h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t2"}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t2", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusForbidden, resp.Code) + }) + + t.Run("deleted tenant jwt returns 401", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) + h.tenantStore.DeleteTenant(t.Context(), "t1") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusUnauthorized, resp.Code) + }) + + t.Run("no auth returns 401", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants", nil) + resp := h.do(req) + + require.Equal(t, http.StatusUnauthorized, resp.Code) + }) +} diff --git a/internal/apirouter/topic_handlers_test.go b/internal/apirouter/topic_handlers_test.go index 3f659549..b765efed 100644 --- a/internal/apirouter/topic_handlers_test.go +++ b/internal/apirouter/topic_handlers_test.go @@ -17,7 +17,7 @@ func TestAPI_Topics(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/topics", nil) resp := h.do(h.withAPIKey(req)) - assert.Equal(t, http.StatusOK, resp.Code) + require.Equal(t, http.StatusOK, resp.Code) var topics []string require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &topics)) @@ -33,7 +33,7 @@ func TestAPI_Topics(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/topics", nil) resp := h.do(h.withJWT(req, "t1")) - assert.Equal(t, http.StatusOK, resp.Code) + require.Equal(t, http.StatusOK, resp.Code) var topics []string require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &topics)) @@ -45,6 +45,6 @@ func TestAPI_Topics(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/topics", nil) resp := h.do(req) - assert.Equal(t, http.StatusUnauthorized, resp.Code) + require.Equal(t, http.StatusUnauthorized, resp.Code) }) } From 5b0d1a23f4211f9f3fb8f8d3cb38243a4522afba Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sat, 31 Jan 2026 20:59:29 +0700 Subject: [PATCH 13/34] test: destination handlers --- .../apirouter/destination_handlers_test.go | 359 ++++++++++++++++++ internal/apirouter/router_test.go | 8 +- internal/apirouter/tenant_handlers_test.go | 26 +- internal/apirouter/topic_handlers_test.go | 8 +- 4 files changed, 383 insertions(+), 18 deletions(-) create mode 100644 internal/apirouter/destination_handlers_test.go diff --git a/internal/apirouter/destination_handlers_test.go b/internal/apirouter/destination_handlers_test.go new file mode 100644 index 00000000..aabce0c3 --- /dev/null +++ b/internal/apirouter/destination_handlers_test.go @@ -0,0 +1,359 @@ +package apirouter_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/hookdeck/outpost/internal/destregistry" + "github.com/hookdeck/outpost/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// validDestination is a minimal valid create-destination payload. +func validDestination() map[string]any { + return map[string]any{ + "type": "webhook", + "topics": []string{"user.created"}, + "config": map[string]string{"url": "https://example.com/hook"}, + } +} + +func TestAPI_Destinations(t *testing.T) { + t.Run("Create", func(t *testing.T) { + t.Run("api key creates destination", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", validDestination()) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusCreated, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + assert.Equal(t, "t1", dest.TenantID) + assert.Equal(t, "webhook", dest.Type) + assert.Equal(t, models.Topics{"user.created"}, dest.Topics) + + // Verify in store + dests, err := h.tenantStore.ListDestinationByTenant(t.Context(), "t1") + require.NoError(t, err) + assert.Len(t, dests, 1) + }) + + t.Run("jwt creates destination on own tenant", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", validDestination()) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusCreated, resp.Code) + }) + + t.Run("missing type returns 422", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", map[string]any{ + "topics": []string{"user.created"}, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + t.Run("missing topics returns 422", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", map[string]any{ + "type": "webhook", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + t.Run("invalid topic returns 422", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", map[string]any{ + "type": "webhook", + "topics": []string{"order.completed"}, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + }) + + t.Run("Retrieve", func(t *testing.T) { + t.Run("api key returns destination", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t1"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/destinations/d1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + assert.Equal(t, "d1", dest.ID) + }) + + t.Run("nonexistent destination returns 404", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/destinations/nope", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusNotFound, resp.Code) + }) + + t.Run("jwt returns destination on own tenant", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t1"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/destinations/d1", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusOK, resp.Code) + }) + }) + + t.Run("List", func(t *testing.T) { + t.Run("api key returns all destinations for tenant", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d2"), df.WithTenantID("t1"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/destinations", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var dests []destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dests)) + assert.Len(t, dests, 2) + }) + + t.Run("jwt returns destinations on own tenant", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t1"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/destinations", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusOK, resp.Code) + + var dests []destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dests)) + assert.Len(t, dests, 1) + }) + }) + + t.Run("Update", func(t *testing.T) { + t.Run("api key updates destination topics", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any( + df.WithID("d1"), df.WithTenantID("t1"), df.WithTopics([]string{"user.created"}), + )) + + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "topics": []string{"user.deleted"}, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + assert.Equal(t, models.Topics{"user.deleted"}, dest.Topics) + }) + + t.Run("api key updates destination config", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any( + df.WithID("d1"), df.WithTenantID("t1"), + df.WithConfig(map[string]string{"url": "https://old.example.com"}), + )) + + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "config": map[string]string{"url": "https://new.example.com"}, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + assert.Equal(t, "https://new.example.com", dest.Config["url"]) + }) + + t.Run("jwt updates destination on own tenant", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any( + df.WithID("d1"), df.WithTenantID("t1"), df.WithTopics([]string{"user.created"}), + )) + + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "topics": []string{"user.deleted"}, + }) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusOK, resp.Code) + }) + + t.Run("nonexistent destination returns 404", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/nope", map[string]any{ + "topics": []string{"user.deleted"}, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusNotFound, resp.Code) + }) + }) + + t.Run("Delete", func(t *testing.T) { + t.Run("api key deletes destination", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t1"))) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/tenants/t1/destinations/d1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + // Subsequent GET returns 404 + req = httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/destinations/d1", nil) + resp = h.do(h.withAPIKey(req)) + assert.Equal(t, http.StatusNotFound, resp.Code) + }) + + t.Run("deleted destination returns 404", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t1"))) + h.tenantStore.DeleteDestination(t.Context(), "t1", "d1") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/destinations/d1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusNotFound, resp.Code) + }) + + t.Run("jwt deletes destination on own tenant", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t1"))) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/tenants/t1/destinations/d1", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusOK, resp.Code) + }) + }) + + t.Run("Enable/Disable", func(t *testing.T) { + t.Run("api key disables destination", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t1"))) + + req := httptest.NewRequest(http.MethodPut, "/api/v1/tenants/t1/destinations/d1/disable", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + assert.NotNil(t, dest.DisabledAt) + }) + + t.Run("api key enables disabled destination", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t1"))) + + // Disable first + req := httptest.NewRequest(http.MethodPut, "/api/v1/tenants/t1/destinations/d1/disable", nil) + h.do(h.withAPIKey(req)) + + // Enable + req = httptest.NewRequest(http.MethodPut, "/api/v1/tenants/t1/destinations/d1/enable", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + assert.Nil(t, dest.DisabledAt) + }) + + t.Run("enable already enabled is noop", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t1"))) + + req := httptest.NewRequest(http.MethodPut, "/api/v1/tenants/t1/destinations/d1/enable", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + assert.Nil(t, dest.DisabledAt) + }) + + t.Run("jwt disable on own tenant", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t1"))) + + req := httptest.NewRequest(http.MethodPut, "/api/v1/tenants/t1/destinations/d1/disable", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusOK, resp.Code) + }) + }) + + t.Run("jwt other tenant returns 403", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t2"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t2"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t2/destinations", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusForbidden, resp.Code) + }) + + t.Run("no auth returns 401", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/destinations", nil) + resp := h.do(req) + + require.Equal(t, http.StatusUnauthorized, resp.Code) + }) +} diff --git a/internal/apirouter/router_test.go b/internal/apirouter/router_test.go index 7144ccb1..f25ac3c6 100644 --- a/internal/apirouter/router_test.go +++ b/internal/apirouter/router_test.go @@ -20,6 +20,7 @@ import ( "github.com/hookdeck/outpost/internal/publishmq" "github.com/hookdeck/outpost/internal/telemetry" "github.com/hookdeck/outpost/internal/tenantstore" + "github.com/hookdeck/outpost/internal/util/testutil" "github.com/uptrace/opentelemetry-go-extra/otelzap" "go.uber.org/zap" ) @@ -33,6 +34,11 @@ const ( testJWTSecret = "test-jwt-secret" ) +var ( + tf = testutil.TenantFactory + df = testutil.DestinationFactory +) + // --------------------------------------------------------------------------- // apiTest harness // --------------------------------------------------------------------------- @@ -60,7 +66,7 @@ func newAPITest(t *testing.T) *apiTest { ServiceName: "test", APIKey: testAPIKey, JWTSecret: testJWTSecret, - Topics: []string{"user.created", "order.completed"}, + Topics: testutil.TestTopics, Registry: &stubRegistry{}, PortalConfig: portal.PortalConfig{}, }, diff --git a/internal/apirouter/tenant_handlers_test.go b/internal/apirouter/tenant_handlers_test.go index 83d83b5b..0dfa1b05 100644 --- a/internal/apirouter/tenant_handlers_test.go +++ b/internal/apirouter/tenant_handlers_test.go @@ -32,7 +32,7 @@ func TestAPI_Tenants(t *testing.T) { h := newAPITest(t) // Create tenant first - h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) // Update with metadata req := h.jsonReq(http.MethodPut, "/api/v1/tenants/t1", map[string]any{ @@ -50,7 +50,7 @@ func TestAPI_Tenants(t *testing.T) { t.Run("jwt updates own tenant", func(t *testing.T) { h := newAPITest(t) - h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) req := h.jsonReq(http.MethodPut, "/api/v1/tenants/t1", map[string]any{ "metadata": map[string]string{"role": "owner"}, @@ -68,7 +68,7 @@ func TestAPI_Tenants(t *testing.T) { t.Run("Retrieve", func(t *testing.T) { t.Run("api key returns tenant", func(t *testing.T) { h := newAPITest(t) - h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1", nil) resp := h.do(h.withAPIKey(req)) @@ -82,7 +82,7 @@ func TestAPI_Tenants(t *testing.T) { t.Run("jwt returns own tenant", func(t *testing.T) { h := newAPITest(t) - h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1", nil) resp := h.do(h.withJWT(req, "t1")) @@ -98,8 +98,8 @@ func TestAPI_Tenants(t *testing.T) { t.Run("List", func(t *testing.T) { t.Run("api key returns all tenants", func(t *testing.T) { h := newAPITest(t) - h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) - h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t2"}) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t2"))) req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants", nil) resp := h.do(h.withAPIKey(req)) @@ -114,8 +114,8 @@ func TestAPI_Tenants(t *testing.T) { t.Run("jwt returns only own tenant", func(t *testing.T) { h := newAPITest(t) - h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) - h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t2"}) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t2"))) req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants", nil) resp := h.do(h.withJWT(req, "t1")) @@ -133,7 +133,7 @@ func TestAPI_Tenants(t *testing.T) { t.Run("Delete", func(t *testing.T) { t.Run("api key deletes tenant", func(t *testing.T) { h := newAPITest(t) - h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) req := httptest.NewRequest(http.MethodDelete, "/api/v1/tenants/t1", nil) resp := h.do(h.withAPIKey(req)) @@ -148,7 +148,7 @@ func TestAPI_Tenants(t *testing.T) { t.Run("jwt deletes own tenant", func(t *testing.T) { h := newAPITest(t) - h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) req := httptest.NewRequest(http.MethodDelete, "/api/v1/tenants/t1", nil) resp := h.do(h.withJWT(req, "t1")) @@ -163,8 +163,8 @@ func TestAPI_Tenants(t *testing.T) { t.Run("jwt other tenant returns 403", func(t *testing.T) { h := newAPITest(t) - h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) - h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t2"}) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t2"))) req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t2", nil) resp := h.do(h.withJWT(req, "t1")) @@ -174,7 +174,7 @@ func TestAPI_Tenants(t *testing.T) { t.Run("deleted tenant jwt returns 401", func(t *testing.T) { h := newAPITest(t) - h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) h.tenantStore.DeleteTenant(t.Context(), "t1") req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1", nil) diff --git a/internal/apirouter/topic_handlers_test.go b/internal/apirouter/topic_handlers_test.go index b765efed..c9ecbfb3 100644 --- a/internal/apirouter/topic_handlers_test.go +++ b/internal/apirouter/topic_handlers_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "testing" - "github.com/hookdeck/outpost/internal/models" + "github.com/hookdeck/outpost/internal/util/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -21,14 +21,14 @@ func TestAPI_Topics(t *testing.T) { var topics []string require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &topics)) - assert.Equal(t, []string{"user.created", "order.completed"}, topics) + assert.Equal(t, testutil.TestTopics, topics) }) t.Run("with JWT returns topics", func(t *testing.T) { h := newAPITest(t) // JWT auth middleware resolves the tenant, so it must exist - h.tenantStore.UpsertTenant(t.Context(), models.Tenant{ID: "t1"}) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) req := httptest.NewRequest(http.MethodGet, "/api/v1/topics", nil) resp := h.do(h.withJWT(req, "t1")) @@ -37,7 +37,7 @@ func TestAPI_Topics(t *testing.T) { var topics []string require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &topics)) - assert.Equal(t, []string{"user.created", "order.completed"}, topics) + assert.Equal(t, testutil.TestTopics, topics) }) t.Run("without auth returns 401", func(t *testing.T) { From 809b6366c81aac4b7e43946af9fbf94641cdc9e5 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sat, 31 Jan 2026 22:01:50 +0700 Subject: [PATCH 14/34] test: log handlers --- internal/apirouter/log_handlers_test.go | 580 ++++++++++++++++++++++++ internal/apirouter/router_test.go | 2 + 2 files changed, 582 insertions(+) create mode 100644 internal/apirouter/log_handlers_test.go diff --git a/internal/apirouter/log_handlers_test.go b/internal/apirouter/log_handlers_test.go new file mode 100644 index 00000000..e55ec1f6 --- /dev/null +++ b/internal/apirouter/log_handlers_test.go @@ -0,0 +1,580 @@ +package apirouter_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/hookdeck/outpost/internal/apirouter" + "github.com/hookdeck/outpost/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// attemptForEvent creates an attempt that references the given event. +func attemptForEvent(event *models.Event, opts ...func(*models.Attempt)) *models.Attempt { + return af.AnyPointer(append([]func(*models.Attempt){ + af.WithEventID(event.ID), + af.WithTenantID(event.TenantID), + af.WithDestinationID(event.DestinationID), + }, opts...)...) +} + +func TestAPI_Events(t *testing.T) { + t.Run("List", func(t *testing.T) { + t.Run("api key returns all events", func(t *testing.T) { + h := newAPITest(t) + + e1 := ef.AnyPointer(ef.WithTenantID("t1")) + e2 := ef.AnyPointer(ef.WithTenantID("t2")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e1, Attempt: attemptForEvent(e1)}, + {Event: e2, Attempt: attemptForEvent(e2)}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/events", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Len(t, result.Models, 2) + }) + + t.Run("api key with tenant_id filter", func(t *testing.T) { + h := newAPITest(t) + + e1 := ef.AnyPointer(ef.WithTenantID("t1")) + e2 := ef.AnyPointer(ef.WithTenantID("t2")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e1, Attempt: attemptForEvent(e1)}, + {Event: e2, Attempt: attemptForEvent(e2)}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?tenant_id=t1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Len(t, result.Models, 1) + assert.Equal(t, e1.ID, result.Models[0].ID) + }) + + t.Run("api key with topic filter", func(t *testing.T) { + h := newAPITest(t) + + e1 := ef.AnyPointer(ef.WithTenantID("t1"), ef.WithTopic("user.created")) + e2 := ef.AnyPointer(ef.WithTenantID("t1"), ef.WithTopic("user.updated")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e1, Attempt: attemptForEvent(e1)}, + {Event: e2, Attempt: attemptForEvent(e2)}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?topic=user.created", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Len(t, result.Models, 1) + assert.Equal(t, "user.created", result.Models[0].Topic) + }) + + t.Run("default pagination metadata", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/events", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Equal(t, "time", result.Pagination.OrderBy) + assert.Equal(t, "desc", result.Pagination.Dir) + assert.Equal(t, 100, result.Pagination.Limit) + assert.Nil(t, result.Pagination.Next) + assert.Nil(t, result.Pagination.Prev) + }) + + t.Run("jwt returns own tenant events", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + e1 := ef.AnyPointer(ef.WithTenantID("t1")) + e2 := ef.AnyPointer(ef.WithTenantID("t2")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e1, Attempt: attemptForEvent(e1)}, + {Event: e2, Attempt: attemptForEvent(e2)}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/events", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Len(t, result.Models, 1) + assert.Equal(t, e1.ID, result.Models[0].ID) + }) + + t.Run("jwt with matching tenant_id returns 200", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + e1 := ef.AnyPointer(ef.WithTenantID("t1")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e1, Attempt: attemptForEvent(e1)}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?tenant_id=t1", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Len(t, result.Models, 1) + }) + + t.Run("jwt with mismatched tenant_id returns 403", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?tenant_id=t2", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusForbidden, resp.Code) + }) + }) + + t.Run("Retrieve", func(t *testing.T) { + t.Run("api key returns event", func(t *testing.T) { + h := newAPITest(t) + + e := ef.AnyPointer(ef.WithID("e1"), ef.WithTenantID("t1")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: attemptForEvent(e)}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/events/e1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var event apirouter.APIEvent + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &event)) + assert.Equal(t, "e1", event.ID) + }) + + t.Run("nonexistent event returns 404", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/events/nope", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusNotFound, resp.Code) + }) + + t.Run("jwt returns own event", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + e := ef.AnyPointer(ef.WithID("e1"), ef.WithTenantID("t1")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: attemptForEvent(e)}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/events/e1", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusOK, resp.Code) + + var event apirouter.APIEvent + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &event)) + assert.Equal(t, "e1", event.ID) + }) + + t.Run("jwt other tenant event returns 404", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + e := ef.AnyPointer(ef.WithID("e1"), ef.WithTenantID("t2")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: attemptForEvent(e)}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/events/e1", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusNotFound, resp.Code) + }) + }) + + t.Run("no auth returns 401", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/events", nil) + resp := h.do(req) + + require.Equal(t, http.StatusUnauthorized, resp.Code) + }) +} + +func TestAPI_Attempts(t *testing.T) { + t.Run("List", func(t *testing.T) { + t.Run("api key returns all attempts", func(t *testing.T) { + h := newAPITest(t) + + e1 := ef.AnyPointer(ef.WithTenantID("t1")) + e2 := ef.AnyPointer(ef.WithTenantID("t2")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e1, Attempt: attemptForEvent(e1)}, + {Event: e2, Attempt: attemptForEvent(e2)}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Len(t, result.Models, 2) + }) + + t.Run("api key with tenant_id filter", func(t *testing.T) { + h := newAPITest(t) + + e1 := ef.AnyPointer(ef.WithTenantID("t1")) + e2 := ef.AnyPointer(ef.WithTenantID("t2")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e1, Attempt: attemptForEvent(e1)}, + {Event: e2, Attempt: attemptForEvent(e2)}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts?tenant_id=t1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Len(t, result.Models, 1) + }) + + t.Run("jwt returns own tenant attempts", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + e1 := ef.AnyPointer(ef.WithTenantID("t1")) + e2 := ef.AnyPointer(ef.WithTenantID("t2")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e1, Attempt: attemptForEvent(e1)}, + {Event: e2, Attempt: attemptForEvent(e2)}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Len(t, result.Models, 1) + }) + + t.Run("jwt with mismatched tenant_id returns 403", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts?tenant_id=t2", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusForbidden, resp.Code) + }) + }) + + t.Run("Retrieve", func(t *testing.T) { + t.Run("api key returns attempt", func(t *testing.T) { + h := newAPITest(t) + + e := ef.AnyPointer(ef.WithTenantID("t1")) + a := attemptForEvent(e, af.WithID("a1")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: a}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts/a1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var attempt apirouter.APIAttempt + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &attempt)) + assert.Equal(t, "a1", attempt.ID) + }) + + t.Run("nonexistent attempt returns 404", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts/nope", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusNotFound, resp.Code) + }) + + t.Run("jwt returns own attempt", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + e := ef.AnyPointer(ef.WithTenantID("t1")) + a := attemptForEvent(e, af.WithID("a1")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: a}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts/a1", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusOK, resp.Code) + + var attempt apirouter.APIAttempt + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &attempt)) + assert.Equal(t, "a1", attempt.ID) + }) + + t.Run("jwt other tenant attempt returns 404", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + e := ef.AnyPointer(ef.WithTenantID("t2")) + a := attemptForEvent(e, af.WithID("a1")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: a}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts/a1", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusNotFound, resp.Code) + }) + + t.Run("include event expands event summary", func(t *testing.T) { + h := newAPITest(t) + + e := ef.AnyPointer(ef.WithID("e1"), ef.WithTenantID("t1"), ef.WithTopic("user.created")) + a := attemptForEvent(e, af.WithID("a1")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: a}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts/a1?include=event", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var raw map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &raw)) + + // With include=event, the event field is an object (not just an ID) + eventMap, ok := raw["event"].(map[string]any) + require.True(t, ok, "event should be an object when include=event") + assert.Equal(t, "e1", eventMap["id"]) + assert.Equal(t, "user.created", eventMap["topic"]) + // Summary does not include data + _, hasData := eventMap["data"] + assert.False(t, hasData) + }) + + t.Run("include event.data expands event with data", func(t *testing.T) { + h := newAPITest(t) + + e := ef.AnyPointer( + ef.WithID("e1"), ef.WithTenantID("t1"), + ef.WithData(map[string]any{"key": "val"}), + ) + a := attemptForEvent(e, af.WithID("a1")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: a}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts/a1?include=event.data", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var raw map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &raw)) + + eventMap, ok := raw["event"].(map[string]any) + require.True(t, ok, "event should be an object when include=event.data") + assert.Equal(t, "e1", eventMap["id"]) + dataMap, ok := eventMap["data"].(map[string]any) + require.True(t, ok, "event.data should be present") + assert.Equal(t, "val", dataMap["key"]) + }) + }) + + t.Run("DestinationAttempts", func(t *testing.T) { + t.Run("List", func(t *testing.T) { + t.Run("api key returns attempts for destination", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t1"))) + + e1 := ef.AnyPointer(ef.WithTenantID("t1"), ef.WithDestinationID("d1")) + e2 := ef.AnyPointer(ef.WithTenantID("t1"), ef.WithDestinationID("d2")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e1, Attempt: attemptForEvent(e1)}, + {Event: e2, Attempt: attemptForEvent(e2)}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/destinations/d1/attempts", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Len(t, result.Models, 1) + assert.Equal(t, "d1", result.Models[0].Destination) + }) + + t.Run("excludes attempts from other tenants same destination", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t1"))) + + e1 := ef.AnyPointer(ef.WithTenantID("t1"), ef.WithDestinationID("d1")) + e2 := ef.AnyPointer(ef.WithTenantID("t2"), ef.WithDestinationID("d1")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e1, Attempt: attemptForEvent(e1)}, + {Event: e2, Attempt: attemptForEvent(e2)}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/destinations/d1/attempts", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Len(t, result.Models, 1) + }) + + t.Run("jwt returns attempts for own destination", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t1"))) + + e := ef.AnyPointer(ef.WithTenantID("t1"), ef.WithDestinationID("d1")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: attemptForEvent(e)}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/destinations/d1/attempts", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Len(t, result.Models, 1) + }) + + t.Run("jwt other tenant returns 403", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t2"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t2"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t2/destinations/d1/attempts", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusForbidden, resp.Code) + }) + }) + + t.Run("Retrieve", func(t *testing.T) { + t.Run("api key retrieves specific attempt", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t1"))) + + e := ef.AnyPointer(ef.WithTenantID("t1"), ef.WithDestinationID("d1")) + a := attemptForEvent(e, af.WithID("a1")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: a}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/destinations/d1/attempts/a1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var attempt apirouter.APIAttempt + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &attempt)) + assert.Equal(t, "a1", attempt.ID) + assert.Equal(t, "d1", attempt.Destination) + }) + + t.Run("attempt from different destination still returned", func(t *testing.T) { + // RetrieveAttempt filters by tenant only, not by destination in path. + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d2"), df.WithTenantID("t1"))) + + e := ef.AnyPointer(ef.WithTenantID("t1"), ef.WithDestinationID("d2")) + a := attemptForEvent(e, af.WithID("a1")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: a}, + })) + + // Request via d1's path, but attempt belongs to d2 + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/destinations/d1/attempts/a1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var attempt apirouter.APIAttempt + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &attempt)) + assert.Equal(t, "d2", attempt.Destination) + }) + + t.Run("jwt other tenant returns 403", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t2"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t2"))) + + e := ef.AnyPointer(ef.WithTenantID("t2"), ef.WithDestinationID("d1")) + a := attemptForEvent(e, af.WithID("a1")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: a}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t2/destinations/d1/attempts/a1", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusForbidden, resp.Code) + }) + }) + }) + + t.Run("no auth returns 401", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts", nil) + resp := h.do(req) + + require.Equal(t, http.StatusUnauthorized, resp.Code) + }) +} diff --git a/internal/apirouter/router_test.go b/internal/apirouter/router_test.go index f25ac3c6..72467f61 100644 --- a/internal/apirouter/router_test.go +++ b/internal/apirouter/router_test.go @@ -37,6 +37,8 @@ const ( var ( tf = testutil.TenantFactory df = testutil.DestinationFactory + ef = testutil.EventFactory + af = testutil.AttemptFactory ) // --------------------------------------------------------------------------- From a3beb16a1f844696d437a3bc62eaa42086a2a297 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sat, 31 Jan 2026 22:37:12 +0700 Subject: [PATCH 15/34] test: resource parent & authz --- .../apirouter/destination_handlers_test.go | 64 ++++++++++++++++ internal/apirouter/log_handlers_test.go | 75 +++++++++++++++++-- 2 files changed, 133 insertions(+), 6 deletions(-) diff --git a/internal/apirouter/destination_handlers_test.go b/internal/apirouter/destination_handlers_test.go index aabce0c3..93f4714b 100644 --- a/internal/apirouter/destination_handlers_test.go +++ b/internal/apirouter/destination_handlers_test.go @@ -128,6 +128,18 @@ func TestAPI_Destinations(t *testing.T) { require.Equal(t, http.StatusOK, resp.Code) }) + + t.Run("destination belonging to other tenant returns 404", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t2"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t2"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/destinations/d1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusNotFound, resp.Code) + }) }) t.Run("List", func(t *testing.T) { @@ -229,6 +241,22 @@ func TestAPI_Destinations(t *testing.T) { require.Equal(t, http.StatusNotFound, resp.Code) }) + + t.Run("destination belonging to other tenant returns 404", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t2"))) + h.tenantStore.CreateDestination(t.Context(), df.Any( + df.WithID("d1"), df.WithTenantID("t2"), df.WithTopics([]string{"user.created"}), + )) + + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "topics": []string{"user.deleted"}, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusNotFound, resp.Code) + }) }) t.Run("Delete", func(t *testing.T) { @@ -270,6 +298,18 @@ func TestAPI_Destinations(t *testing.T) { require.Equal(t, http.StatusOK, resp.Code) }) + + t.Run("destination belonging to other tenant returns 404", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t2"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t2"))) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/tenants/t1/destinations/d1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusNotFound, resp.Code) + }) }) t.Run("Enable/Disable", func(t *testing.T) { @@ -333,6 +373,30 @@ func TestAPI_Destinations(t *testing.T) { require.Equal(t, http.StatusOK, resp.Code) }) + + t.Run("enable destination belonging to other tenant returns 404", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t2"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t2"))) + + req := httptest.NewRequest(http.MethodPut, "/api/v1/tenants/t1/destinations/d1/enable", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusNotFound, resp.Code) + }) + + t.Run("disable destination belonging to other tenant returns 404", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t2"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t2"))) + + req := httptest.NewRequest(http.MethodPut, "/api/v1/tenants/t1/destinations/d1/disable", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusNotFound, resp.Code) + }) }) t.Run("jwt other tenant returns 403", func(t *testing.T) { diff --git a/internal/apirouter/log_handlers_test.go b/internal/apirouter/log_handlers_test.go index e55ec1f6..de12019e 100644 --- a/internal/apirouter/log_handlers_test.go +++ b/internal/apirouter/log_handlers_test.go @@ -500,6 +500,33 @@ func TestAPI_Attempts(t *testing.T) { require.Equal(t, http.StatusForbidden, resp.Code) }) + + t.Run("destination belonging to other tenant returns empty list without leaking data", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t2"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t2"))) + + e := ef.AnyPointer(ef.WithTenantID("t2"), ef.WithDestinationID("d1")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: attemptForEvent(e)}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/destinations/d1/attempts", nil) + resp := h.do(h.withAPIKey(req)) + + // The handler does not validate destination ownership — it passes the + // destinationID straight to the log store as a filter alongside the + // tenant ID. When the destination belongs to another tenant, the query + // returns no matches because no attempts exist for that (tenant, destination) + // pair. This means no data leaks, but the API returns 200 with an empty + // list instead of 404. + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Empty(t, result.Models, "must not leak attempts from other tenants") + }) }) t.Run("Retrieve", func(t *testing.T) { @@ -525,8 +552,7 @@ func TestAPI_Attempts(t *testing.T) { assert.Equal(t, "d1", attempt.Destination) }) - t.Run("attempt from different destination still returned", func(t *testing.T) { - // RetrieveAttempt filters by tenant only, not by destination in path. + t.Run("attempt belonging to different destination returns 404", func(t *testing.T) { h := newAPITest(t) h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t1"))) @@ -542,11 +568,27 @@ func TestAPI_Attempts(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/destinations/d1/attempts/a1", nil) resp := h.do(h.withAPIKey(req)) - require.Equal(t, http.StatusOK, resp.Code) + require.Equal(t, http.StatusNotFound, resp.Code) + }) - var attempt apirouter.APIAttempt - require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &attempt)) - assert.Equal(t, "d2", attempt.Destination) + t.Run("attempt belonging to other tenant destination returns 404", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t2"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d2"), df.WithTenantID("t2"))) + + e := ef.AnyPointer(ef.WithTenantID("t2"), ef.WithDestinationID("d2")) + a := attemptForEvent(e, af.WithID("a1")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: a}, + })) + + // d1 belongs to t1 (valid), but a1 belongs to d2/t2 + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/destinations/d1/attempts/a1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusNotFound, resp.Code) }) t.Run("jwt other tenant returns 403", func(t *testing.T) { @@ -566,6 +608,27 @@ func TestAPI_Attempts(t *testing.T) { require.Equal(t, http.StatusForbidden, resp.Code) }) + + t.Run("destination belonging to other tenant does not leak data", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t2"))) + h.tenantStore.CreateDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t2"))) + + e := ef.AnyPointer(ef.WithTenantID("t2"), ef.WithDestinationID("d1")) + a := attemptForEvent(e, af.WithID("a1")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: a}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/destinations/d1/attempts/a1", nil) + resp := h.do(h.withAPIKey(req)) + + // The handler filters by tenant ID, not destination ownership. + // The attempt belongs to t2 so the tenant filter excludes it — returns + // 404 with no data leaked. + require.Equal(t, http.StatusNotFound, resp.Code) + }) }) }) From 4af930a006acbf7055022aad57856a538b5c0688 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sat, 31 Jan 2026 22:41:32 +0700 Subject: [PATCH 16/34] fix: validate destination ownership in RetrieveAttempt --- internal/apirouter/log_handlers.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/apirouter/log_handlers.go b/internal/apirouter/log_handlers.go index 43e4f63c..df20340e 100644 --- a/internal/apirouter/log_handlers.go +++ b/internal/apirouter/log_handlers.go @@ -357,6 +357,15 @@ func (h *LogHandlers) RetrieveAttempt(c *gin.Context) { return } + // Authz: when accessed via a destination-scoped route, verify the attempt + // belongs to the destination in the path. + if destinationID := c.Param("destinationID"); destinationID != "" { + if attemptRecord.Attempt.DestinationID != destinationID { + AbortWithError(c, http.StatusNotFound, NewErrNotFound("attempt")) + return + } + } + includeOpts := parseIncludeOptions(c) c.JSON(http.StatusOK, toAPIAttempt(attemptRecord, includeOpts)) From 6f4435e45e175c31f3c9edfb78480a3e9a7c5afb Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sat, 31 Jan 2026 23:11:55 +0700 Subject: [PATCH 17/34] test: comprehensive list & pagination tests --- .../apirouter/destination_handlers_test.go | 37 ++ internal/apirouter/log_handlers_test.go | 521 ++++++++++++++++++ internal/apirouter/tenant_handlers_test.go | 150 +++++ 3 files changed, 708 insertions(+) diff --git a/internal/apirouter/destination_handlers_test.go b/internal/apirouter/destination_handlers_test.go index 93f4714b..54017a7f 100644 --- a/internal/apirouter/destination_handlers_test.go +++ b/internal/apirouter/destination_handlers_test.go @@ -173,6 +173,43 @@ func TestAPI_Destinations(t *testing.T) { require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dests)) assert.Len(t, dests, 1) }) + + t.Run("Filtering", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.CreateDestination(t.Context(), df.Any( + df.WithID("d1"), df.WithTenantID("t1"), + df.WithType("webhook"), df.WithTopics([]string{"user.created"}), + )) + h.tenantStore.CreateDestination(t.Context(), df.Any( + df.WithID("d2"), df.WithTenantID("t1"), + df.WithType("aws_sqs"), df.WithTopics([]string{"user.deleted"}), + )) + + t.Run("type filter", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/destinations?type=webhook", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var dests []destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dests)) + require.Len(t, dests, 1) + assert.Equal(t, "d1", dests[0].ID) + }) + + t.Run("topics filter", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/destinations?topics=user.created", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var dests []destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dests)) + require.Len(t, dests, 1) + assert.Equal(t, "d1", dests[0].ID) + }) + }) }) t.Run("Update", func(t *testing.T) { diff --git a/internal/apirouter/log_handlers_test.go b/internal/apirouter/log_handlers_test.go index de12019e..3b3760ec 100644 --- a/internal/apirouter/log_handlers_test.go +++ b/internal/apirouter/log_handlers_test.go @@ -2,9 +2,12 @@ package apirouter_test import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" + "net/url" "testing" + "time" "github.com/hookdeck/outpost/internal/apirouter" "github.com/hookdeck/outpost/internal/models" @@ -152,6 +155,280 @@ func TestAPI_Events(t *testing.T) { require.Equal(t, http.StatusForbidden, resp.Code) }) + + // Pagination, filtering, and validation are tested comprehensively under + // TestAPI_Attempts since attempts are the primary query surface. Events + // share the same underlying pagination/filter machinery (ParseCursors, + // ParseDir, ParseOrderBy, ParseDateFilter) so we keep a lighter smoke + // suite here to confirm the wiring without duplicating every scenario. + + t.Run("Pagination", func(t *testing.T) { + h := newAPITest(t) + + now := time.Now() + e1 := ef.AnyPointer(ef.WithID("e1"), ef.WithTenantID("t1"), ef.WithTime(now.Add(-2*time.Second))) + e2 := ef.AnyPointer(ef.WithID("e2"), ef.WithTenantID("t1"), ef.WithTime(now.Add(-1*time.Second))) + e3 := ef.AnyPointer(ef.WithID("e3"), ef.WithTenantID("t1"), ef.WithTime(now)) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e1, Attempt: attemptForEvent(e1)}, + {Event: e2, Attempt: attemptForEvent(e2)}, + {Event: e3, Attempt: attemptForEvent(e3)}, + })) + + t.Run("forward pagination returns pages in order", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?limit=1&dir=desc", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + require.Len(t, result.Models, 1) + assert.Equal(t, "e3", result.Models[0].ID) + assert.NotNil(t, result.Pagination.Next) + assert.Nil(t, result.Pagination.Prev) + }) + + t.Run("next cursor returns second page", func(t *testing.T) { + // Get first page + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?limit=1&dir=desc", nil) + resp := h.do(h.withAPIKey(req)) + var page1 apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page1)) + require.NotNil(t, page1.Pagination.Next) + + // Get second page + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/events?limit=1&next=%s", *page1.Pagination.Next), nil) + resp = h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var page2 apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page2)) + require.Len(t, page2.Models, 1) + assert.Equal(t, "e2", page2.Models[0].ID) + assert.NotNil(t, page2.Pagination.Next) + assert.NotNil(t, page2.Pagination.Prev) + }) + + t.Run("last page has no next cursor", func(t *testing.T) { + // Get first page + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?limit=1&dir=desc", nil) + resp := h.do(h.withAPIKey(req)) + var page1 apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page1)) + + // Get second page + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/events?limit=1&next=%s", *page1.Pagination.Next), nil) + resp = h.do(h.withAPIKey(req)) + var page2 apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page2)) + + // Get third (last) page + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/events?limit=1&next=%s", *page2.Pagination.Next), nil) + resp = h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var page3 apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page3)) + require.Len(t, page3.Models, 1) + assert.Equal(t, "e1", page3.Models[0].ID) + assert.Nil(t, page3.Pagination.Next) + assert.NotNil(t, page3.Pagination.Prev) + }) + + t.Run("prev cursor returns previous page", func(t *testing.T) { + // Navigate to last page + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?limit=1&dir=desc", nil) + resp := h.do(h.withAPIKey(req)) + var page1 apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page1)) + + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/events?limit=1&next=%s", *page1.Pagination.Next), nil) + resp = h.do(h.withAPIKey(req)) + var page2 apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page2)) + + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/events?limit=1&next=%s", *page2.Pagination.Next), nil) + resp = h.do(h.withAPIKey(req)) + var page3 apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page3)) + require.NotNil(t, page3.Pagination.Prev) + + // Go back + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/events?limit=1&prev=%s", *page3.Pagination.Prev), nil) + resp = h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var prevPage apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &prevPage)) + require.Len(t, prevPage.Models, 1) + assert.Equal(t, "e2", prevPage.Models[0].ID) + }) + + t.Run("dir asc reverses order", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?limit=1&dir=asc", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + require.Len(t, result.Models, 1) + assert.Equal(t, "e1", result.Models[0].ID) + }) + + t.Run("limit caps results", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?limit=2", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Len(t, result.Models, 2) + assert.NotNil(t, result.Pagination.Next) + }) + }) + + t.Run("Filtering", func(t *testing.T) { + h := newAPITest(t) + + now := time.Now() + e1 := ef.AnyPointer( + ef.WithID("e1"), ef.WithTenantID("t1"), ef.WithDestinationID("d1"), + ef.WithTopic("user.created"), ef.WithTime(now.Add(-2*time.Hour)), + ) + e2 := ef.AnyPointer( + ef.WithID("e2"), ef.WithTenantID("t1"), ef.WithDestinationID("d2"), + ef.WithTopic("user.updated"), ef.WithTime(now), + ) + e3 := ef.AnyPointer( + ef.WithID("e3"), ef.WithTenantID("t2"), ef.WithDestinationID("d3"), + ef.WithTopic("user.created"), ef.WithTime(now), + ) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e1, Attempt: attemptForEvent(e1)}, + {Event: e2, Attempt: attemptForEvent(e2)}, + {Event: e3, Attempt: attemptForEvent(e3)}, + })) + + t.Run("destination_id filter", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?destination_id=d1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + require.Len(t, result.Models, 1) + assert.Equal(t, "e1", result.Models[0].ID) + }) + + t.Run("multiple topics filter", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?topic=user.created&topic=user.updated", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Len(t, result.Models, 3) + }) + + t.Run("single topic filter", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?topic=user.updated", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + require.Len(t, result.Models, 1) + assert.Equal(t, "e2", result.Models[0].ID) + }) + + t.Run("time gte filter", func(t *testing.T) { + cutoff := now.Add(-1 * time.Hour).UTC().Format(time.RFC3339) + v := url.Values{} + v.Set("time[gte]", cutoff) + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?"+v.Encode(), nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Len(t, result.Models, 2) + }) + + t.Run("time lte filter", func(t *testing.T) { + cutoff := now.Add(-1 * time.Hour).UTC().Format(time.RFC3339) + v := url.Values{} + v.Set("time[lte]", cutoff) + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?"+v.Encode(), nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + require.Len(t, result.Models, 1) + assert.Equal(t, "e1", result.Models[0].ID) + }) + + t.Run("combined filters", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?topic=user.created&tenant_id=t1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + require.Len(t, result.Models, 1) + assert.Equal(t, "e1", result.Models[0].ID) + }) + }) + + t.Run("Validation", func(t *testing.T) { + t.Run("invalid dir returns 422", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?dir=sideways", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + t.Run("invalid order_by returns 422", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?order_by=name", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + t.Run("both next and prev returns 400", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?next=abc&prev=def", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusBadRequest, resp.Code) + }) + + t.Run("invalid date format returns 422", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?time[gte]=not-a-date", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + }) }) t.Run("Retrieve", func(t *testing.T) { @@ -299,6 +576,250 @@ func TestAPI_Attempts(t *testing.T) { require.Equal(t, http.StatusForbidden, resp.Code) }) + + t.Run("Pagination", func(t *testing.T) { + h := newAPITest(t) + + now := time.Now() + e1 := ef.AnyPointer(ef.WithID("e1"), ef.WithTenantID("t1"), ef.WithTime(now.Add(-2*time.Second))) + e2 := ef.AnyPointer(ef.WithID("e2"), ef.WithTenantID("t1"), ef.WithTime(now.Add(-1*time.Second))) + e3 := ef.AnyPointer(ef.WithID("e3"), ef.WithTenantID("t1"), ef.WithTime(now)) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e1, Attempt: attemptForEvent(e1, af.WithID("a1"), af.WithTime(now.Add(-2*time.Second)))}, + {Event: e2, Attempt: attemptForEvent(e2, af.WithID("a2"), af.WithTime(now.Add(-1*time.Second)))}, + {Event: e3, Attempt: attemptForEvent(e3, af.WithID("a3"), af.WithTime(now))}, + })) + + t.Run("forward pagination returns pages in order", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts?limit=1&dir=desc", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + require.Len(t, result.Models, 1) + assert.Equal(t, "a3", result.Models[0].ID) + assert.NotNil(t, result.Pagination.Next) + assert.Nil(t, result.Pagination.Prev) + }) + + t.Run("next cursor returns second page", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts?limit=1&dir=desc", nil) + resp := h.do(h.withAPIKey(req)) + var page1 apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page1)) + require.NotNil(t, page1.Pagination.Next) + + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/attempts?limit=1&next=%s", *page1.Pagination.Next), nil) + resp = h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var page2 apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page2)) + require.Len(t, page2.Models, 1) + assert.Equal(t, "a2", page2.Models[0].ID) + assert.NotNil(t, page2.Pagination.Next) + assert.NotNil(t, page2.Pagination.Prev) + }) + + t.Run("last page has no next cursor", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts?limit=1&dir=desc", nil) + resp := h.do(h.withAPIKey(req)) + var page1 apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page1)) + + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/attempts?limit=1&next=%s", *page1.Pagination.Next), nil) + resp = h.do(h.withAPIKey(req)) + var page2 apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page2)) + + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/attempts?limit=1&next=%s", *page2.Pagination.Next), nil) + resp = h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var page3 apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page3)) + require.Len(t, page3.Models, 1) + assert.Equal(t, "a1", page3.Models[0].ID) + assert.Nil(t, page3.Pagination.Next) + assert.NotNil(t, page3.Pagination.Prev) + }) + + t.Run("prev cursor returns previous page", func(t *testing.T) { + // Navigate to last page + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts?limit=1&dir=desc", nil) + resp := h.do(h.withAPIKey(req)) + var page1 apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page1)) + + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/attempts?limit=1&next=%s", *page1.Pagination.Next), nil) + resp = h.do(h.withAPIKey(req)) + var page2 apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page2)) + + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/attempts?limit=1&next=%s", *page2.Pagination.Next), nil) + resp = h.do(h.withAPIKey(req)) + var page3 apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page3)) + require.NotNil(t, page3.Pagination.Prev) + + // Go back + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/attempts?limit=1&prev=%s", *page3.Pagination.Prev), nil) + resp = h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var prevPage apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &prevPage)) + require.Len(t, prevPage.Models, 1) + assert.Equal(t, "a2", prevPage.Models[0].ID) + }) + + t.Run("dir asc reverses order", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts?limit=1&dir=asc", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + require.Len(t, result.Models, 1) + assert.Equal(t, "a1", result.Models[0].ID) + }) + + t.Run("limit caps results", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts?limit=2", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Len(t, result.Models, 2) + assert.NotNil(t, result.Pagination.Next) + }) + }) + + t.Run("Filtering", func(t *testing.T) { + h := newAPITest(t) + + now := time.Now() + e1 := ef.AnyPointer(ef.WithID("e1"), ef.WithTenantID("t1"), ef.WithDestinationID("d1"), ef.WithTopic("user.created")) + e2 := ef.AnyPointer(ef.WithID("e2"), ef.WithTenantID("t1"), ef.WithDestinationID("d2"), ef.WithTopic("user.updated")) + a1 := attemptForEvent(e1, af.WithID("a1"), af.WithStatus("success"), af.WithTime(now.Add(-2*time.Hour))) + a2 := attemptForEvent(e2, af.WithID("a2"), af.WithStatus("failed"), af.WithTime(now)) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e1, Attempt: a1}, + {Event: e2, Attempt: a2}, + })) + + t.Run("status filter", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts?status=success", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + require.Len(t, result.Models, 1) + assert.Equal(t, "a1", result.Models[0].ID) + }) + + t.Run("event_id filter", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts?event_id=e1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + require.Len(t, result.Models, 1) + assert.Equal(t, "a1", result.Models[0].ID) + }) + + t.Run("destination_id filter", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts?destination_id=d1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + require.Len(t, result.Models, 1) + assert.Equal(t, "a1", result.Models[0].ID) + }) + + t.Run("time gte filter", func(t *testing.T) { + cutoff := now.Add(-1 * time.Hour).UTC().Format(time.RFC3339) + v := url.Values{} + v.Set("time[gte]", cutoff) + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts?"+v.Encode(), nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + require.Len(t, result.Models, 1) + assert.Equal(t, "a2", result.Models[0].ID) + }) + + t.Run("time lte filter", func(t *testing.T) { + cutoff := now.Add(-1 * time.Hour).UTC().Format(time.RFC3339) + v := url.Values{} + v.Set("time[lte]", cutoff) + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts?"+v.Encode(), nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + require.Len(t, result.Models, 1) + assert.Equal(t, "a1", result.Models[0].ID) + }) + }) + + t.Run("Validation", func(t *testing.T) { + t.Run("invalid dir returns 422", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts?dir=sideways", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + t.Run("invalid order_by returns 422", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts?order_by=name", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + t.Run("both next and prev returns 400", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts?next=abc&prev=def", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusBadRequest, resp.Code) + }) + + t.Run("invalid date format returns 422", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts?time[gte]=not-a-date", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + }) }) t.Run("Retrieve", func(t *testing.T) { diff --git a/internal/apirouter/tenant_handlers_test.go b/internal/apirouter/tenant_handlers_test.go index 0dfa1b05..35dd377a 100644 --- a/internal/apirouter/tenant_handlers_test.go +++ b/internal/apirouter/tenant_handlers_test.go @@ -2,9 +2,11 @@ package apirouter_test import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" + "time" "github.com/hookdeck/outpost/internal/models" "github.com/hookdeck/outpost/internal/tenantstore" @@ -128,6 +130,154 @@ func TestAPI_Tenants(t *testing.T) { assert.Len(t, result.Models, 1) assert.Equal(t, "t1", result.Models[0].ID) }) + + t.Run("Pagination", func(t *testing.T) { + h := newAPITest(t) + + now := time.Now() + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"), tf.WithCreatedAt(now.Add(-2*time.Second)))) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t2"), tf.WithCreatedAt(now.Add(-1*time.Second)))) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t3"), tf.WithCreatedAt(now))) + + t.Run("forward pagination first page", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants?limit=1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result tenantstore.TenantPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + require.Len(t, result.Models, 1) + assert.Equal(t, "t3", result.Models[0].ID) + assert.Equal(t, 3, result.Count) + assert.NotNil(t, result.Pagination.Next) + assert.Nil(t, result.Pagination.Prev) + }) + + t.Run("next cursor returns second page", func(t *testing.T) { + // Get first page + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants?limit=1", nil) + resp := h.do(h.withAPIKey(req)) + var page1 tenantstore.TenantPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page1)) + require.NotNil(t, page1.Pagination.Next) + + // Get second page + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/tenants?limit=1&next=%s", *page1.Pagination.Next), nil) + resp = h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var page2 tenantstore.TenantPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page2)) + require.Len(t, page2.Models, 1) + assert.Equal(t, "t2", page2.Models[0].ID) + assert.Equal(t, 3, page2.Count) + assert.NotNil(t, page2.Pagination.Next) + assert.NotNil(t, page2.Pagination.Prev) + }) + + t.Run("last page has no next cursor", func(t *testing.T) { + // Navigate to last page + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants?limit=1", nil) + resp := h.do(h.withAPIKey(req)) + var page1 tenantstore.TenantPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page1)) + + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/tenants?limit=1&next=%s", *page1.Pagination.Next), nil) + resp = h.do(h.withAPIKey(req)) + var page2 tenantstore.TenantPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page2)) + + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/tenants?limit=1&next=%s", *page2.Pagination.Next), nil) + resp = h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var page3 tenantstore.TenantPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page3)) + require.Len(t, page3.Models, 1) + assert.Equal(t, "t1", page3.Models[0].ID) + assert.Equal(t, 3, page3.Count) + assert.Nil(t, page3.Pagination.Next) + assert.NotNil(t, page3.Pagination.Prev) + }) + + t.Run("prev cursor returns previous page", func(t *testing.T) { + // Navigate to last page + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants?limit=1", nil) + resp := h.do(h.withAPIKey(req)) + var page1 tenantstore.TenantPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page1)) + + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/tenants?limit=1&next=%s", *page1.Pagination.Next), nil) + resp = h.do(h.withAPIKey(req)) + var page2 tenantstore.TenantPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page2)) + + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/tenants?limit=1&next=%s", *page2.Pagination.Next), nil) + resp = h.do(h.withAPIKey(req)) + var page3 tenantstore.TenantPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &page3)) + require.NotNil(t, page3.Pagination.Prev) + + // Go back + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/tenants?limit=1&prev=%s", *page3.Pagination.Prev), nil) + resp = h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var prevPage tenantstore.TenantPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &prevPage)) + require.Len(t, prevPage.Models, 1) + assert.Equal(t, "t2", prevPage.Models[0].ID) + }) + + t.Run("dir asc reverses order", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants?limit=1&dir=asc", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result tenantstore.TenantPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + require.Len(t, result.Models, 1) + assert.Equal(t, "t1", result.Models[0].ID) + }) + + t.Run("limit caps results", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants?limit=2", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result tenantstore.TenantPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Len(t, result.Models, 2) + assert.Equal(t, 3, result.Count) + assert.NotNil(t, result.Pagination.Next) + }) + }) + + t.Run("Validation", func(t *testing.T) { + t.Run("invalid dir returns 422", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants?dir=sideways", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + t.Run("both next and prev returns 400", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants?next=abc&prev=def", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusBadRequest, resp.Code) + }) + }) }) t.Run("Delete", func(t *testing.T) { From 562160cc7d4c08feb9caff104662a0548a928223 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sat, 31 Jan 2026 23:24:14 +0700 Subject: [PATCH 18/34] test: list tenant not supported --- internal/apirouter/router_test.go | 23 ++++++++++++++++++++-- internal/apirouter/tenant_handlers_test.go | 21 ++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/internal/apirouter/router_test.go b/internal/apirouter/router_test.go index 72467f61..7b5c8470 100644 --- a/internal/apirouter/router_test.go +++ b/internal/apirouter/router_test.go @@ -54,11 +54,30 @@ type apiTest struct { eventHandler *mockEventHandler } -func newAPITest(t *testing.T) *apiTest { +type apiTestOption func(*apiTestConfig) + +type apiTestConfig struct { + tenantStore tenantstore.TenantStore +} + +func withTenantStore(ts tenantstore.TenantStore) apiTestOption { + return func(cfg *apiTestConfig) { + cfg.tenantStore = ts + } +} + +func newAPITest(t *testing.T, opts ...apiTestOption) *apiTest { t.Helper() + cfg := apiTestConfig{ + tenantStore: tenantstore.NewMemTenantStore(), + } + for _, o := range opts { + o(&cfg) + } + logger := &logging.Logger{Logger: otelzap.New(zap.NewNop())} - ts := tenantstore.NewMemTenantStore() + ts := cfg.tenantStore ls := logstore.NewMemLogStore() dp := &mockDeliveryPublisher{} eh := &mockEventHandler{} diff --git a/internal/apirouter/tenant_handlers_test.go b/internal/apirouter/tenant_handlers_test.go index 35dd377a..fbd69994 100644 --- a/internal/apirouter/tenant_handlers_test.go +++ b/internal/apirouter/tenant_handlers_test.go @@ -1,6 +1,7 @@ package apirouter_test import ( + "context" "encoding/json" "fmt" "net/http" @@ -14,6 +15,17 @@ import ( "github.com/stretchr/testify/require" ) +// listUnsupportedStore wraps a TenantStore and overrides ListTenant +// to return ErrListTenantNotSupported, simulating a store backend +// (e.g., Redis without RediSearch) that doesn't support listing. +type listUnsupportedStore struct { + tenantstore.TenantStore +} + +func (s *listUnsupportedStore) ListTenant(_ context.Context, _ tenantstore.ListTenantRequest) (*tenantstore.TenantPaginatedResult, error) { + return nil, tenantstore.ErrListTenantNotSupported +} + func TestAPI_Tenants(t *testing.T) { t.Run("Upsert", func(t *testing.T) { t.Run("api key creates tenant", func(t *testing.T) { @@ -278,6 +290,15 @@ func TestAPI_Tenants(t *testing.T) { require.Equal(t, http.StatusBadRequest, resp.Code) }) }) + + t.Run("list not supported returns 501", func(t *testing.T) { + h := newAPITest(t, withTenantStore(&listUnsupportedStore{tenantstore.NewMemTenantStore()})) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusNotImplemented, resp.Code) + }) }) t.Run("Delete", func(t *testing.T) { From 6db798ef700b9110c86cfb26e2b19ff8736675a4 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sun, 1 Feb 2026 01:06:25 +0700 Subject: [PATCH 19/34] test: publish & retry api tests --- internal/apirouter/publish_handlers_test.go | 338 ++++++++++++++++++++ internal/apirouter/retry_handlers_test.go | 286 +++++++++++++++++ internal/apirouter/router_test.go | 3 +- 3 files changed, 626 insertions(+), 1 deletion(-) create mode 100644 internal/apirouter/publish_handlers_test.go create mode 100644 internal/apirouter/retry_handlers_test.go diff --git a/internal/apirouter/publish_handlers_test.go b/internal/apirouter/publish_handlers_test.go new file mode 100644 index 00000000..8ac6693a --- /dev/null +++ b/internal/apirouter/publish_handlers_test.go @@ -0,0 +1,338 @@ +package apirouter_test + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/hookdeck/outpost/internal/idempotence" + "github.com/hookdeck/outpost/internal/models" + "github.com/hookdeck/outpost/internal/publishmq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPI_Publish(t *testing.T) { + t.Run("Auth", func(t *testing.T) { + t.Run("no auth returns 401", func(t *testing.T) { + h := newAPITest(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "t1", + }) + resp := h.do(req) + + require.Equal(t, http.StatusUnauthorized, resp.Code) + }) + + t.Run("jwt returns 401", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "t1", + }) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusUnauthorized, resp.Code) + }) + + t.Run("api key succeeds", func(t *testing.T) { + h := newAPITest(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "t1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusAccepted, resp.Code) + }) + }) + + t.Run("Validation", func(t *testing.T) { + t.Run("empty JSON returns 422", func(t *testing.T) { + h := newAPITest(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{}) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + t.Run("missing tenant_id returns 422", func(t *testing.T) { + h := newAPITest(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "topic": "user.created", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + t.Run("no body returns 400", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/publish", nil) + req.Header.Set("Content-Type", "application/json") + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusBadRequest, resp.Code) + }) + }) + + t.Run("Error mapping", func(t *testing.T) { + t.Run("idempotency conflict returns 409", func(t *testing.T) { + h := newAPITest(t) + h.eventHandler.err = idempotence.ErrConflict + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "t1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusConflict, resp.Code) + }) + + t.Run("required topic returns 422 with detail", func(t *testing.T) { + h := newAPITest(t) + h.eventHandler.err = publishmq.ErrRequiredTopic + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "t1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + + var body map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + data := body["data"].(map[string]any) + assert.Equal(t, "required", data["topic"]) + }) + + t.Run("invalid topic returns 422 with detail", func(t *testing.T) { + h := newAPITest(t) + h.eventHandler.err = publishmq.ErrInvalidTopic + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "t1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + + var body map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + data := body["data"].(map[string]any) + assert.Equal(t, "invalid", data["topic"]) + }) + + t.Run("internal error returns 500", func(t *testing.T) { + h := newAPITest(t) + h.eventHandler.err = errors.New("database error") + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "t1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusInternalServerError, resp.Code) + }) + }) + + t.Run("Success", func(t *testing.T) { + t.Run("returns event ID", func(t *testing.T) { + h := newAPITest(t) + h.eventHandler.result = &publishmq.HandleResult{EventID: "evt-123"} + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "t1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusAccepted, resp.Code) + + var result publishmq.HandleResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Equal(t, "evt-123", result.EventID) + assert.False(t, result.Duplicate) + }) + + t.Run("returns duplicate flag", func(t *testing.T) { + h := newAPITest(t) + h.eventHandler.result = &publishmq.HandleResult{EventID: "evt-123", Duplicate: true} + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "t1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusAccepted, resp.Code) + + var result publishmq.HandleResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.True(t, result.Duplicate) + }) + }) + + t.Run("Input defaults", func(t *testing.T) { + t.Run("auto-generates ID when omitted", func(t *testing.T) { + h := newAPITest(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "t1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusAccepted, resp.Code) + require.Len(t, h.eventHandler.calls, 1) + assert.NotEmpty(t, h.eventHandler.calls[0].ID) + }) + + t.Run("uses explicit ID", func(t *testing.T) { + h := newAPITest(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "id": "custom-id", + "tenant_id": "t1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusAccepted, resp.Code) + require.Len(t, h.eventHandler.calls, 1) + assert.Equal(t, "custom-id", h.eventHandler.calls[0].ID) + }) + + t.Run("defaults time to now", func(t *testing.T) { + h := newAPITest(t) + before := time.Now() + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "t1", + }) + resp := h.do(h.withAPIKey(req)) + after := time.Now() + + require.Equal(t, http.StatusAccepted, resp.Code) + require.Len(t, h.eventHandler.calls, 1) + eventTime := h.eventHandler.calls[0].Time + assert.False(t, eventTime.Before(before)) + assert.False(t, eventTime.After(after)) + }) + + t.Run("uses explicit time", func(t *testing.T) { + h := newAPITest(t) + explicit := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "t1", + "time": explicit.Format(time.RFC3339), + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusAccepted, resp.Code) + require.Len(t, h.eventHandler.calls, 1) + assert.True(t, h.eventHandler.calls[0].Time.Equal(explicit)) + }) + + t.Run("defaults eligible_for_retry to true", func(t *testing.T) { + h := newAPITest(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "t1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusAccepted, resp.Code) + require.Len(t, h.eventHandler.calls, 1) + assert.True(t, h.eventHandler.calls[0].EligibleForRetry) + }) + + t.Run("eligible_for_retry false", func(t *testing.T) { + h := newAPITest(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "t1", + "eligible_for_retry": false, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusAccepted, resp.Code) + require.Len(t, h.eventHandler.calls, 1) + assert.False(t, h.eventHandler.calls[0].EligibleForRetry) + }) + + t.Run("preserves tenant_id", func(t *testing.T) { + h := newAPITest(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "my-tenant", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusAccepted, resp.Code) + require.Len(t, h.eventHandler.calls, 1) + assert.Equal(t, "my-tenant", h.eventHandler.calls[0].TenantID) + }) + + t.Run("preserves topic", func(t *testing.T) { + h := newAPITest(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "t1", + "topic": "user.created", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusAccepted, resp.Code) + require.Len(t, h.eventHandler.calls, 1) + assert.Equal(t, "user.created", h.eventHandler.calls[0].Topic) + }) + + t.Run("preserves destination_id", func(t *testing.T) { + h := newAPITest(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "t1", + "destination_id": "dest-1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusAccepted, resp.Code) + require.Len(t, h.eventHandler.calls, 1) + assert.Equal(t, "dest-1", h.eventHandler.calls[0].DestinationID) + }) + + t.Run("preserves metadata", func(t *testing.T) { + h := newAPITest(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "t1", + "metadata": map[string]string{"env": "prod"}, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusAccepted, resp.Code) + require.Len(t, h.eventHandler.calls, 1) + assert.Equal(t, models.Metadata{"env": "prod"}, h.eventHandler.calls[0].Metadata) + }) + + t.Run("preserves data", func(t *testing.T) { + h := newAPITest(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "t1", + "data": map[string]any{"foo": "bar"}, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusAccepted, resp.Code) + require.Len(t, h.eventHandler.calls, 1) + assert.Equal(t, "bar", h.eventHandler.calls[0].Data["foo"]) + }) + }) +} diff --git a/internal/apirouter/retry_handlers_test.go b/internal/apirouter/retry_handlers_test.go new file mode 100644 index 00000000..acd4dd3a --- /dev/null +++ b/internal/apirouter/retry_handlers_test.go @@ -0,0 +1,286 @@ +package apirouter_test + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/hookdeck/outpost/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPI_Retry(t *testing.T) { + // setup creates a standard test harness with a tenant, destination, and event + // that are all compatible for a successful retry. + setup := func(t *testing.T, opts ...apiTestOption) *apiTest { + t.Helper() + h := newAPITest(t, opts...) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.UpsertDestination(t.Context(), df.Any(df.WithID("d1"), df.WithTenantID("t1"), df.WithTopics([]string{"*"}))) + e := ef.AnyPointer(ef.WithID("e1"), ef.WithTenantID("t1"), ef.WithTopic("user.created")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: attemptForEvent(e)}, + })) + return h + } + + t.Run("Auth", func(t *testing.T) { + t.Run("no auth returns 401", func(t *testing.T) { + h := setup(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/retry", map[string]any{ + "event_id": "e1", + "destination_id": "d1", + }) + resp := h.do(req) + + require.Equal(t, http.StatusUnauthorized, resp.Code) + }) + + t.Run("api key succeeds", func(t *testing.T) { + h := setup(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/retry", map[string]any{ + "event_id": "e1", + "destination_id": "d1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusAccepted, resp.Code) + }) + + t.Run("jwt own tenant succeeds", func(t *testing.T) { + h := setup(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/retry", map[string]any{ + "event_id": "e1", + "destination_id": "d1", + }) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusAccepted, resp.Code) + }) + }) + + t.Run("Validation", func(t *testing.T) { + t.Run("no body returns 400", func(t *testing.T) { + h := setup(t) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/retry", nil) + req.Header.Set("Content-Type", "application/json") + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusBadRequest, resp.Code) + }) + + t.Run("empty JSON returns 400", func(t *testing.T) { + h := setup(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/retry", map[string]any{}) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusBadRequest, resp.Code) + }) + + t.Run("missing event_id returns 400", func(t *testing.T) { + h := setup(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/retry", map[string]any{ + "destination_id": "d1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusBadRequest, resp.Code) + }) + + t.Run("missing destination_id returns 400", func(t *testing.T) { + h := setup(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/retry", map[string]any{ + "event_id": "e1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusBadRequest, resp.Code) + }) + }) + + t.Run("Event lookup", func(t *testing.T) { + t.Run("event not found returns 404", func(t *testing.T) { + h := setup(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/retry", map[string]any{ + "event_id": "nonexistent", + "destination_id": "d1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusNotFound, resp.Code) + }) + }) + + t.Run("Tenant isolation", func(t *testing.T) { + t.Run("jwt other tenant event returns 404", func(t *testing.T) { + h := newAPITest(t) + // Create two tenants + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t2"))) + dest := df.Any(df.WithID("d1"), df.WithTenantID("t1"), df.WithTopics([]string{"*"})) + h.tenantStore.UpsertDestination(t.Context(), dest) + // Event belongs to t1 + e := ef.AnyPointer(ef.WithID("e1"), ef.WithTenantID("t1"), ef.WithTopic("user.created")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: attemptForEvent(e)}, + })) + + // JWT for t2 tries to retry t1's event + req := h.jsonReq(http.MethodPost, "/api/v1/retry", map[string]any{ + "event_id": "e1", + "destination_id": "d1", + }) + resp := h.do(h.withJWT(req, "t2")) + + require.Equal(t, http.StatusNotFound, resp.Code) + }) + + t.Run("api key can access any tenant event", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + dest := df.Any(df.WithID("d1"), df.WithTenantID("t1"), df.WithTopics([]string{"*"})) + h.tenantStore.UpsertDestination(t.Context(), dest) + e := ef.AnyPointer(ef.WithID("e1"), ef.WithTenantID("t1"), ef.WithTopic("user.created")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: attemptForEvent(e)}, + })) + + req := h.jsonReq(http.MethodPost, "/api/v1/retry", map[string]any{ + "event_id": "e1", + "destination_id": "d1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusAccepted, resp.Code) + }) + }) + + t.Run("Destination checks", func(t *testing.T) { + t.Run("destination not found returns 404", func(t *testing.T) { + h := setup(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/retry", map[string]any{ + "event_id": "e1", + "destination_id": "nonexistent", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusNotFound, resp.Code) + }) + + t.Run("disabled destination returns 400", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + now := time.Now() + dest := df.Any(df.WithID("d1"), df.WithTenantID("t1"), df.WithTopics([]string{"*"}), df.WithDisabledAt(now)) + h.tenantStore.UpsertDestination(t.Context(), dest) + e := ef.AnyPointer(ef.WithID("e1"), ef.WithTenantID("t1"), ef.WithTopic("user.created")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: attemptForEvent(e)}, + })) + + req := h.jsonReq(http.MethodPost, "/api/v1/retry", map[string]any{ + "event_id": "e1", + "destination_id": "d1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusBadRequest, resp.Code) + }) + + t.Run("topic mismatch returns 400", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + // Destination only accepts "user.deleted" + dest := df.Any(df.WithID("d1"), df.WithTenantID("t1"), df.WithTopics([]string{"user.deleted"})) + h.tenantStore.UpsertDestination(t.Context(), dest) + // Event has topic "user.created" + e := ef.AnyPointer(ef.WithID("e1"), ef.WithTenantID("t1"), ef.WithTopic("user.created")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: attemptForEvent(e)}, + })) + + req := h.jsonReq(http.MethodPost, "/api/v1/retry", map[string]any{ + "event_id": "e1", + "destination_id": "d1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusBadRequest, resp.Code) + }) + + t.Run("wildcard destination matches any topic", func(t *testing.T) { + h := setup(t) // setup uses topics: ["*"] + + req := h.jsonReq(http.MethodPost, "/api/v1/retry", map[string]any{ + "event_id": "e1", + "destination_id": "d1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusAccepted, resp.Code) + }) + }) + + t.Run("Delivery task", func(t *testing.T) { + t.Run("queues manual delivery task", func(t *testing.T) { + h := setup(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/retry", map[string]any{ + "event_id": "e1", + "destination_id": "d1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusAccepted, resp.Code) + require.Len(t, h.deliveryPub.calls, 1) + + task := h.deliveryPub.calls[0] + assert.True(t, task.Manual) + assert.Equal(t, "e1", task.Event.ID) + assert.Equal(t, "t1", task.Event.TenantID) + assert.Equal(t, "d1", task.DestinationID) + }) + + t.Run("returns success body", func(t *testing.T) { + h := setup(t) + + req := h.jsonReq(http.MethodPost, "/api/v1/retry", map[string]any{ + "event_id": "e1", + "destination_id": "d1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusAccepted, resp.Code) + + var body map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + assert.Equal(t, true, body["success"]) + }) + + t.Run("publisher error returns 500", func(t *testing.T) { + h := setup(t) + h.deliveryPub.err = errors.New("queue unavailable") + + req := h.jsonReq(http.MethodPost, "/api/v1/retry", map[string]any{ + "event_id": "e1", + "destination_id": "d1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusInternalServerError, resp.Code) + }) + }) +} diff --git a/internal/apirouter/router_test.go b/internal/apirouter/router_test.go index 7b5c8470..c25837fd 100644 --- a/internal/apirouter/router_test.go +++ b/internal/apirouter/router_test.go @@ -160,11 +160,12 @@ func (a *apiTest) withJWT(req *http.Request, tenantID string) *http.Request { // mockDeliveryPublisher records Publish calls. type mockDeliveryPublisher struct { calls []models.DeliveryTask + err error } func (m *mockDeliveryPublisher) Publish(_ context.Context, task models.DeliveryTask) error { m.calls = append(m.calls, task) - return nil + return m.err } // mockEventHandler records Handle calls with configurable return values. From b0948303fba81a96b9c38d8a21f1373c710fcee5 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sun, 1 Feb 2026 01:15:49 +0700 Subject: [PATCH 20/34] fix: consistent validation handling --- internal/apirouter/publish_handlers.go | 8 ++------ internal/apirouter/publish_handlers_test.go | 10 ++++++---- internal/apirouter/retry_handlers.go | 2 +- internal/apirouter/retry_handlers_test.go | 12 ++++++------ 4 files changed, 15 insertions(+), 17 deletions(-) diff --git a/internal/apirouter/publish_handlers.go b/internal/apirouter/publish_handlers.go index 3a84c3d3..4cc9df1c 100644 --- a/internal/apirouter/publish_handlers.go +++ b/internal/apirouter/publish_handlers.go @@ -49,18 +49,14 @@ func (h *PublishHandlers) Ingest(c *gin.Context) { Code: http.StatusUnprocessableEntity, Message: "validation error", Err: err, - Data: map[string]string{ - "topic": "required", - }, + Data: []string{"topic is required"}, }) } else if errors.Is(err, publishmq.ErrInvalidTopic) { AbortWithValidationError(c, ErrorResponse{ Code: http.StatusUnprocessableEntity, Message: "validation error", Err: err, - Data: map[string]string{ - "topic": "invalid", - }, + Data: []string{"topic is invalid"}, }) } else { AbortWithError(c, http.StatusInternalServerError, NewErrInternalServer(err)) diff --git a/internal/apirouter/publish_handlers_test.go b/internal/apirouter/publish_handlers_test.go index 8ac6693a..bb3d651e 100644 --- a/internal/apirouter/publish_handlers_test.go +++ b/internal/apirouter/publish_handlers_test.go @@ -110,8 +110,9 @@ func TestAPI_Publish(t *testing.T) { var body map[string]any require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) - data := body["data"].(map[string]any) - assert.Equal(t, "required", data["topic"]) + data, ok := body["data"].([]any) + require.True(t, ok) + assert.Contains(t, data, "topic is required") }) t.Run("invalid topic returns 422 with detail", func(t *testing.T) { @@ -127,8 +128,9 @@ func TestAPI_Publish(t *testing.T) { var body map[string]any require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) - data := body["data"].(map[string]any) - assert.Equal(t, "invalid", data["topic"]) + data, ok := body["data"].([]any) + require.True(t, ok) + assert.Contains(t, data, "topic is invalid") }) t.Run("internal error returns 500", func(t *testing.T) { diff --git a/internal/apirouter/retry_handlers.go b/internal/apirouter/retry_handlers.go index 29f44625..116d2a62 100644 --- a/internal/apirouter/retry_handlers.go +++ b/internal/apirouter/retry_handlers.go @@ -48,7 +48,7 @@ type retryRequest struct { func (h *RetryHandlers) Retry(c *gin.Context) { var req retryRequest if err := c.ShouldBindJSON(&req); err != nil { - AbortWithError(c, http.StatusBadRequest, NewErrBadRequest(err)) + AbortWithValidationError(c, err) return } diff --git a/internal/apirouter/retry_handlers_test.go b/internal/apirouter/retry_handlers_test.go index acd4dd3a..4cffbbc9 100644 --- a/internal/apirouter/retry_handlers_test.go +++ b/internal/apirouter/retry_handlers_test.go @@ -77,16 +77,16 @@ func TestAPI_Retry(t *testing.T) { require.Equal(t, http.StatusBadRequest, resp.Code) }) - t.Run("empty JSON returns 400", func(t *testing.T) { + t.Run("empty JSON returns 422", func(t *testing.T) { h := setup(t) req := h.jsonReq(http.MethodPost, "/api/v1/retry", map[string]any{}) resp := h.do(h.withAPIKey(req)) - require.Equal(t, http.StatusBadRequest, resp.Code) + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) }) - t.Run("missing event_id returns 400", func(t *testing.T) { + t.Run("missing event_id returns 422", func(t *testing.T) { h := setup(t) req := h.jsonReq(http.MethodPost, "/api/v1/retry", map[string]any{ @@ -94,10 +94,10 @@ func TestAPI_Retry(t *testing.T) { }) resp := h.do(h.withAPIKey(req)) - require.Equal(t, http.StatusBadRequest, resp.Code) + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) }) - t.Run("missing destination_id returns 400", func(t *testing.T) { + t.Run("missing destination_id returns 422", func(t *testing.T) { h := setup(t) req := h.jsonReq(http.MethodPost, "/api/v1/retry", map[string]any{ @@ -105,7 +105,7 @@ func TestAPI_Retry(t *testing.T) { }) resp := h.do(h.withAPIKey(req)) - require.Equal(t, http.StatusBadRequest, resp.Code) + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) }) }) From 65dc7993982a807c6280bbd9b63c2af908ee9f08 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sun, 1 Feb 2026 01:32:54 +0700 Subject: [PATCH 21/34] fix: clean up dead code and nil-check inconsistency in apirouter --- internal/apirouter/auth_middleware.go | 32 --------------------------- internal/apirouter/log_handlers.go | 27 ++++++++-------------- 2 files changed, 9 insertions(+), 50 deletions(-) diff --git a/internal/apirouter/auth_middleware.go b/internal/apirouter/auth_middleware.go index ed8403ab..7232c56f 100644 --- a/internal/apirouter/auth_middleware.go +++ b/internal/apirouter/auth_middleware.go @@ -213,35 +213,3 @@ func mustTenantFromContext(c *gin.Context) *models.Tenant { } return tenant.(*models.Tenant) } - -func TenantJWTAuthMiddleware(apiKey string, jwtKey string) gin.HandlerFunc { - return func(c *gin.Context) { - // When apiKey or jwtKey is empty, JWT-only routes should not exist - if apiKey == "" || jwtKey == "" { - c.AbortWithStatus(http.StatusNotFound) - return - } - - token, err := validateAuthHeader(c) - if err != nil { - c.AbortWithStatus(http.StatusUnauthorized) - return - } - - claims, err := JWT.Extract(jwtKey, token) - if err != nil { - c.AbortWithStatus(http.StatusUnauthorized) - return - } - - // If tenantID param exists, verify it matches token - if paramTenantID := c.Param("tenantID"); paramTenantID != "" && paramTenantID != claims.TenantID { - c.AbortWithStatus(http.StatusForbidden) - return - } - - c.Set("tenantID", claims.TenantID) - c.Set(authRoleKey, RoleTenant) - c.Next() - } -} diff --git a/internal/apirouter/log_handlers.go b/internal/apirouter/log_handlers.go index df20340e..a8cc23de 100644 --- a/internal/apirouter/log_handlers.go +++ b/internal/apirouter/log_handlers.go @@ -62,7 +62,6 @@ func parseLimit(c *gin.Context, defaultLimit, maxLimit int) int { type IncludeOptions struct { Event bool EventData bool - Destination bool ResponseData bool } @@ -75,8 +74,6 @@ func parseIncludeOptions(c *gin.Context) IncludeOptions { case "event.data": opts.Event = true opts.EventData = true - case "destination": - opts.Destination = true case "response_data": opts.ResponseData = true } @@ -97,8 +94,9 @@ type APIAttempt struct { Manual bool `json:"manual"` // Expandable fields - string (ID) or object depending on expand - Event interface{} `json:"event"` - Destination string `json:"destination"` + Event interface{} `json:"event"` + + Destination string `json:"destination"` } // APIEventSummary is the event object when expand=event (without data) @@ -145,19 +143,17 @@ type EventPaginatedResult struct { // toAPIAttempt converts an AttemptRecord to APIAttempt with expand options func toAPIAttempt(ar *logstore.AttemptRecord, opts IncludeOptions) APIAttempt { api := APIAttempt{ + ID: ar.Attempt.ID, + Status: ar.Attempt.Status, + DeliveredAt: ar.Attempt.Time, + Code: ar.Attempt.Code, AttemptNumber: ar.Attempt.AttemptNumber, Manual: ar.Attempt.Manual, Destination: ar.Attempt.DestinationID, } - if ar.Attempt != nil { - api.ID = ar.Attempt.ID - api.Status = ar.Attempt.Status - api.DeliveredAt = ar.Attempt.Time - api.Code = ar.Attempt.Code - if opts.ResponseData { - api.ResponseData = ar.Attempt.ResponseData - } + if opts.ResponseData { + api.ResponseData = ar.Attempt.ResponseData } if ar.Event != nil { @@ -185,11 +181,6 @@ func toAPIAttempt(ar *logstore.AttemptRecord, opts IncludeOptions) APIAttempt { api.Event = ar.Attempt.EventID } - // TODO: Handle destination expansion - // This would require injecting TenantStore into LogHandlers and batch-fetching - // destinations by ID. Consider if this is needed - clients can fetch destination - // details separately via GET /destinations/:id if needed. - return api } From 96e1442408fcfd9587a7d06051d5b07779c90545 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sun, 1 Feb 2026 02:03:27 +0700 Subject: [PATCH 22/34] test: add missing apirouter test coverage for auth, tokens, portal, and destination types --- internal/apirouter/auth_middleware_test.go | 223 ++++++++++++++++++ .../apirouter/destination_handlers_test.go | 67 ++++++ internal/apirouter/log_handlers_test.go | 40 ++++ internal/apirouter/publish_handlers_test.go | 2 +- internal/apirouter/tenant_handlers_test.go | 170 +++++++++++++ 5 files changed, 501 insertions(+), 1 deletion(-) create mode 100644 internal/apirouter/auth_middleware_test.go diff --git a/internal/apirouter/auth_middleware_test.go b/internal/apirouter/auth_middleware_test.go new file mode 100644 index 00000000..fd409b56 --- /dev/null +++ b/internal/apirouter/auth_middleware_test.go @@ -0,0 +1,223 @@ +package apirouter_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/hookdeck/outpost/internal/apirouter" + "github.com/hookdeck/outpost/internal/models" + "github.com/hookdeck/outpost/internal/tenantstore" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// retrieveErrorStore wraps a TenantStore and overrides RetrieveTenant +// to return a configurable error, simulating a store failure. +type retrieveErrorStore struct { + tenantstore.TenantStore + err error +} + +func (s *retrieveErrorStore) RetrieveTenant(_ context.Context, _ string) (*models.Tenant, error) { + return nil, s.err +} + +func TestAuthMiddleware(t *testing.T) { + // okHandler is a simple handler that returns 200 when reached. + okHandler := func(c *gin.Context) { + c.Status(http.StatusOK) + } + + t.Run("AdminMiddleware", func(t *testing.T) { + t.Run("vpc mode grants admin without auth header", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AdminMiddleware(""), okHandler) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("vpc mode grants admin ignores auth header", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AdminMiddleware(""), okHandler) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer wrong-key") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("valid api key returns 200", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AdminMiddleware(testAPIKey), okHandler) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer "+testAPIKey) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("missing auth header returns 401", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AdminMiddleware(testAPIKey), okHandler) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("malformed bearer prefix returns 401", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AdminMiddleware(testAPIKey), okHandler) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Basic "+testAPIKey) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("empty bearer token returns 401", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AdminMiddleware(testAPIKey), okHandler) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer ") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("wrong api key returns 401", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AdminMiddleware(testAPIKey), okHandler) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer wrong-key") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + }) + + t.Run("AuthenticatedMiddleware", func(t *testing.T) { + t.Run("vpc mode grants admin without auth header", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AuthenticatedMiddleware("", testJWTSecret), okHandler) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("valid api key returns 200", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AuthenticatedMiddleware(testAPIKey, testJWTSecret), okHandler) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer "+testAPIKey) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("valid jwt returns 200", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AuthenticatedMiddleware(testAPIKey, testJWTSecret), okHandler) + + token, err := apirouter.JWT.New(testJWTSecret, apirouter.JWTClaims{TenantID: "t1"}) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("invalid token neither api key nor jwt returns 401", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AuthenticatedMiddleware(testAPIKey, testJWTSecret), okHandler) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer not-a-valid-token") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("missing auth header returns 401", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AuthenticatedMiddleware(testAPIKey, testJWTSecret), okHandler) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("malformed bearer prefix returns 401", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AuthenticatedMiddleware(testAPIKey, testJWTSecret), okHandler) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Token "+testAPIKey) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("jwt for wrong tenant param returns 403", func(t *testing.T) { + r := gin.New() + r.GET("/test/:tenantID", apirouter.AuthenticatedMiddleware(testAPIKey, testJWTSecret), okHandler) + + token, err := apirouter.JWT.New(testJWTSecret, apirouter.JWTClaims{TenantID: "t1"}) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/test/t2", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + }) + }) + + t.Run("resolveTenantMiddleware", func(t *testing.T) { + t.Run("store error returns 500", func(t *testing.T) { + store := &retrieveErrorStore{ + TenantStore: tenantstore.NewMemTenantStore(), + err: errors.New("database connection failed"), + } + h := newAPITest(t, withTenantStore(store)) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusInternalServerError, resp.Code) + }) + }) +} diff --git a/internal/apirouter/destination_handlers_test.go b/internal/apirouter/destination_handlers_test.go index 54017a7f..932e36a3 100644 --- a/internal/apirouter/destination_handlers_test.go +++ b/internal/apirouter/destination_handlers_test.go @@ -458,3 +458,70 @@ func TestAPI_Destinations(t *testing.T) { require.Equal(t, http.StatusUnauthorized, resp.Code) }) } + +// TestAPI_DestinationTypes tests the /destination-types endpoints. +// Note: response body is a passthrough from the registry stub (returns nil); +// not validated here. 404 path not testable without enhancing the stub. +func TestAPI_DestinationTypes(t *testing.T) { + t.Run("List", func(t *testing.T) { + t.Run("api key returns 200", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/destination-types", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + }) + + t.Run("jwt returns 200", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/destination-types", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusOK, resp.Code) + }) + + t.Run("no auth returns 401", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/destination-types", nil) + resp := h.do(req) + + require.Equal(t, http.StatusUnauthorized, resp.Code) + }) + }) + + t.Run("Retrieve", func(t *testing.T) { + t.Run("api key returns 200", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/destination-types/webhook", nil) + resp := h.do(h.withAPIKey(req)) + + // The stub returns (nil, nil) for RetrieveProviderMetadata, + // so the handler returns 200 with null body. + require.Equal(t, http.StatusOK, resp.Code) + }) + + t.Run("jwt returns 200", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/destination-types/webhook", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusOK, resp.Code) + }) + + t.Run("no auth returns 401", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/destination-types/webhook", nil) + resp := h.do(req) + + require.Equal(t, http.StatusUnauthorized, resp.Code) + }) + }) +} diff --git a/internal/apirouter/log_handlers_test.go b/internal/apirouter/log_handlers_test.go index 3b3760ec..d6b6a545 100644 --- a/internal/apirouter/log_handlers_test.go +++ b/internal/apirouter/log_handlers_test.go @@ -781,6 +781,18 @@ func TestAPI_Attempts(t *testing.T) { require.Len(t, result.Models, 1) assert.Equal(t, "a1", result.Models[0].ID) }) + + t.Run("topic filter", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts?topic=user.created", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var result apirouter.AttemptPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + require.Len(t, result.Models, 1) + assert.Equal(t, "a1", result.Models[0].ID) + }) }) t.Run("Validation", func(t *testing.T) { @@ -941,6 +953,34 @@ func TestAPI_Attempts(t *testing.T) { require.True(t, ok, "event.data should be present") assert.Equal(t, "val", dataMap["key"]) }) + + t.Run("include response data", func(t *testing.T) { + h := newAPITest(t) + + e := ef.AnyPointer(ef.WithID("e1"), ef.WithTenantID("t1")) + a := attemptForEvent(e, af.WithID("a1"), func(att *models.Attempt) { + att.ResponseData = map[string]interface{}{ + "status": "ok", + "body": "response-body", + } + }) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: a}, + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts/a1?include=response_data", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var raw map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &raw)) + + respData, ok := raw["response_data"].(map[string]any) + require.True(t, ok, "response_data should be an object when include=response_data") + assert.Equal(t, "ok", respData["status"]) + assert.Equal(t, "response-body", respData["body"]) + }) }) t.Run("DestinationAttempts", func(t *testing.T) { diff --git a/internal/apirouter/publish_handlers_test.go b/internal/apirouter/publish_handlers_test.go index bb3d651e..81f73ec1 100644 --- a/internal/apirouter/publish_handlers_test.go +++ b/internal/apirouter/publish_handlers_test.go @@ -258,7 +258,7 @@ func TestAPI_Publish(t *testing.T) { h := newAPITest(t) req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ - "tenant_id": "t1", + "tenant_id": "t1", "eligible_for_retry": false, }) resp := h.do(h.withAPIKey(req)) diff --git a/internal/apirouter/tenant_handlers_test.go b/internal/apirouter/tenant_handlers_test.go index fbd69994..c6a9f4b3 100644 --- a/internal/apirouter/tenant_handlers_test.go +++ b/internal/apirouter/tenant_handlers_test.go @@ -6,9 +6,11 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "time" + "github.com/hookdeck/outpost/internal/apirouter" "github.com/hookdeck/outpost/internal/models" "github.com/hookdeck/outpost/internal/tenantstore" "github.com/stretchr/testify/assert" @@ -77,6 +79,32 @@ func TestAPI_Tenants(t *testing.T) { require.NoError(t, err) assert.Equal(t, models.Metadata{"role": "owner"}, tenant.Metadata) }) + + t.Run("jwt nonexistent tenant returns 401", func(t *testing.T) { + h := newAPITest(t) + // t1 doesn't exist — resolveTenantMiddleware rejects before handler runs + req := httptest.NewRequest(http.MethodPut, "/api/v1/tenants/t1", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusUnauthorized, resp.Code) + }) + + t.Run("api key deleted tenant recreates", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.DeleteTenant(t.Context(), "t1") + + // Upsert on deleted tenant should recreate it + req := httptest.NewRequest(http.MethodPut, "/api/v1/tenants/t1", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusCreated, resp.Code) + + // Verify tenant exists again in store + tenant, err := h.tenantStore.RetrieveTenant(t.Context(), "t1") + require.NoError(t, err) + assert.Equal(t, "t1", tenant.ID) + }) }) t.Run("Retrieve", func(t *testing.T) { @@ -362,4 +390,146 @@ func TestAPI_Tenants(t *testing.T) { require.Equal(t, http.StatusUnauthorized, resp.Code) }) + + t.Run("RetrieveToken", func(t *testing.T) { + t.Run("api key returns token and tenant id", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/token", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var body map[string]string + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + assert.Equal(t, "t1", body["tenant_id"]) + assert.NotEmpty(t, body["token"]) + + // Verify the returned JWT is valid and has correct claims + claims, err := apirouter.JWT.Extract(testJWTSecret, body["token"]) + require.NoError(t, err) + assert.Equal(t, "t1", claims.TenantID) + }) + + t.Run("nonexistent tenant returns 404", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/nope/token", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusNotFound, resp.Code) + }) + + t.Run("jwt returns 401", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/token", nil) + resp := h.do(h.withJWT(req, "t1")) + + // Token endpoint is admin-only; JWT auth should be rejected + require.Equal(t, http.StatusUnauthorized, resp.Code) + }) + + t.Run("no auth returns 401", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/token", nil) + resp := h.do(req) + + require.Equal(t, http.StatusUnauthorized, resp.Code) + }) + }) + + t.Run("RetrievePortal", func(t *testing.T) { + t.Run("api key returns redirect url with token", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/portal", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var body map[string]string + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + assert.Equal(t, "t1", body["tenant_id"]) + assert.NotEmpty(t, body["redirect_url"]) + assert.True(t, strings.Contains(body["redirect_url"], "token=")) + }) + + t.Run("theme dark", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/portal?theme=dark", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var body map[string]string + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + assert.True(t, strings.Contains(body["redirect_url"], "theme=dark")) + }) + + t.Run("theme light", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/portal?theme=light", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var body map[string]string + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + assert.True(t, strings.Contains(body["redirect_url"], "theme=light")) + }) + + t.Run("invalid theme omitted", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/portal?theme=neon", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusOK, resp.Code) + + var body map[string]string + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + assert.False(t, strings.Contains(body["redirect_url"], "theme=")) + }) + + t.Run("nonexistent tenant returns 404", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/nope/portal", nil) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusNotFound, resp.Code) + }) + + t.Run("jwt returns 401", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/portal", nil) + resp := h.do(h.withJWT(req, "t1")) + + // Portal endpoint is admin-only; JWT auth should be rejected + require.Equal(t, http.StatusUnauthorized, resp.Code) + }) + + t.Run("no auth returns 401", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/portal", nil) + resp := h.do(req) + + require.Equal(t, http.StatusUnauthorized, resp.Code) + }) + }) } From 49750251d0e7cf0b87f6260507d9d3c6fdfdaec4 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sun, 1 Feb 2026 02:31:49 +0700 Subject: [PATCH 23/34] refactor: consolidate middleware into unified AuthMiddleware --- internal/apirouter/auth_middleware.go | 169 +++++------ internal/apirouter/auth_middleware_test.go | 277 +++++++++--------- internal/apirouter/errorhandler_middleware.go | 2 + .../apirouter/errorhandler_middleware_test.go | 25 ++ internal/apirouter/publish_handlers_test.go | 4 +- internal/apirouter/router.go | 78 ++--- internal/apirouter/tenant_handlers_test.go | 10 +- 7 files changed, 294 insertions(+), 271 deletions(-) diff --git a/internal/apirouter/auth_middleware.go b/internal/apirouter/auth_middleware.go index 7232c56f..97c1f8a5 100644 --- a/internal/apirouter/auth_middleware.go +++ b/internal/apirouter/auth_middleware.go @@ -31,128 +31,106 @@ type TenantRetriever interface { RetrieveTenant(ctx context.Context, tenantID string) (*models.Tenant, error) } -// validateAuthHeader checks the Authorization header and returns the token if valid -func validateAuthHeader(c *gin.Context) (string, error) { - header := c.GetHeader("Authorization") - if header == "" { - return "", ErrMissingAuthHeader - } - if !strings.HasPrefix(header, "Bearer ") { - return "", ErrInvalidBearerToken - } - token := strings.TrimPrefix(header, "Bearer ") - if token == "" { - return "", ErrInvalidBearerToken - } - return token, nil +// AuthOptions configures the behaviour of AuthMiddleware. +type AuthOptions struct { + AdminOnly bool + RequireTenant bool } -func AdminMiddleware(apiKey string) gin.HandlerFunc { - // When apiKey is empty, everything is admin-only through VPC +// AuthMiddleware returns a single gin.HandlerFunc that handles authentication, +// authorization, and tenant resolution for every route. +// +// Flow: +// 1. VPC mode (apiKey=""): grant admin, resolve tenant if RequireTenant, done. +// 2. Validate auth header → 401 if missing/malformed. +// 3. token == apiKey → admin, resolve tenant if RequireTenant, done. +// 4. JWT.Extract(token) → 401 if invalid. +// 5. AdminOnly? → 403. +// 6. :tenantID param mismatch? → 403. +// 7. Set tenantID + RoleTenant, always resolve tenant for JWT → 401 if missing/deleted. +func AuthMiddleware(apiKey, jwtSecret string, tenantRetriever TenantRetriever, opts AuthOptions) gin.HandlerFunc { + // VPC mode — no API key configured, everything is admin. if apiKey == "" { return func(c *gin.Context) { c.Set(authRoleKey, RoleAdmin) + if opts.RequireTenant { + resolveTenantOrAbort(c, tenantRetriever, tenantIDFromContext(c), false) + if c.IsAborted() { + return + } + } c.Next() } } return func(c *gin.Context) { + // 2. Validate auth header token, err := validateAuthHeader(c) if err != nil { c.AbortWithStatus(http.StatusUnauthorized) return } - if token != apiKey { - c.AbortWithStatus(http.StatusUnauthorized) - return - } - - c.Set(authRoleKey, RoleAdmin) - c.Next() - } -} - -func AuthenticatedMiddleware(apiKey string, jwtKey string) gin.HandlerFunc { - // When apiKey is empty, everything is admin-only through VPC - if apiKey == "" { - return func(c *gin.Context) { + // 3. API key match → admin + if token == apiKey { c.Set(authRoleKey, RoleAdmin) + if opts.RequireTenant { + resolveTenantOrAbort(c, tenantRetriever, tenantIDFromContext(c), false) + if c.IsAborted() { + return + } + } c.Next() + return } - } - return func(c *gin.Context) { - token, err := validateAuthHeader(c) + // 4. Try JWT + claims, err := JWT.Extract(jwtSecret, token) if err != nil { c.AbortWithStatus(http.StatusUnauthorized) return } - // Try API key first - if token == apiKey { - c.Set(authRoleKey, RoleAdmin) - c.Next() - return - } - - // Try JWT auth - claims, err := JWT.Extract(jwtKey, token) - if err != nil { - c.AbortWithStatus(http.StatusUnauthorized) + // 5. AdminOnly routes reject JWT tokens + if opts.AdminOnly { + c.AbortWithStatus(http.StatusForbidden) return } - // If tenantID param exists, verify it matches token + // 6. tenantID param mismatch if paramTenantID := c.Param("tenantID"); paramTenantID != "" && paramTenantID != claims.TenantID { c.AbortWithStatus(http.StatusForbidden) return } + // 7. Set tenant context and always resolve for JWT c.Set("tenantID", claims.TenantID) c.Set(authRoleKey, RoleTenant) + resolveTenantOrAbort(c, tenantRetriever, claims.TenantID, true) + if c.IsAborted() { + return + } + c.Next() } } -// resolveTenantMiddleware resolves the tenant from the DB and sets it in context. -// For JWT auth (tenantID already in context), it resolves using that ID. -// For API key auth on tenant-scoped routes, it resolves using the :tenantID URL param. -// When requireTenant is true, missing/deleted tenant returns an error (404 for admin, 401 for JWT). -// When requireTenant is false, it only resolves if JWT set a tenantID in context. -func resolveTenantMiddleware(tenantRetriever TenantRetriever, requireTenant bool) gin.HandlerFunc { - return func(c *gin.Context) { - _, isJWT := c.Get("tenantID") - - if !requireTenant && !isJWT { - c.Next() - return - } - - tenantID := tenantIDFromContext(c) - if tenantID == "" { - if requireTenant { - AbortWithError(c, http.StatusNotFound, NewErrNotFound("tenant")) - } else { - c.Next() - } - return +// resolveTenantOrAbort looks up the tenant and sets it in context. +// When isJWT is true, a missing or deleted tenant results in 401 (token is stale). +// When isJWT is false, a missing or deleted tenant results in 404. +func resolveTenantOrAbort(c *gin.Context, retriever TenantRetriever, tenantID string, isJWT bool) { + if tenantID == "" { + if isJWT { + c.AbortWithStatus(http.StatusUnauthorized) + } else { + AbortWithError(c, http.StatusNotFound, NewErrNotFound("tenant")) } + return + } - tenant, err := tenantRetriever.RetrieveTenant(c.Request.Context(), tenantID) - if err != nil { - if err == tenantstore.ErrTenantDeleted { - if isJWT { - c.AbortWithStatus(http.StatusUnauthorized) - } else { - AbortWithError(c, http.StatusNotFound, NewErrNotFound("tenant")) - } - return - } - AbortWithError(c, http.StatusInternalServerError, NewErrInternalServer(err)) - return - } - if tenant == nil { + tenant, err := retriever.RetrieveTenant(c.Request.Context(), tenantID) + if err != nil { + if err == tenantstore.ErrTenantDeleted { if isJWT { c.AbortWithStatus(http.StatusUnauthorized) } else { @@ -160,10 +138,35 @@ func resolveTenantMiddleware(tenantRetriever TenantRetriever, requireTenant bool } return } + AbortWithError(c, http.StatusInternalServerError, NewErrInternalServer(err)) + return + } + if tenant == nil { + if isJWT { + c.AbortWithStatus(http.StatusUnauthorized) + } else { + AbortWithError(c, http.StatusNotFound, NewErrNotFound("tenant")) + } + return + } - c.Set("tenant", tenant) - c.Next() + c.Set("tenant", tenant) +} + +// validateAuthHeader checks the Authorization header and returns the token if valid +func validateAuthHeader(c *gin.Context) (string, error) { + header := c.GetHeader("Authorization") + if header == "" { + return "", ErrMissingAuthHeader + } + if !strings.HasPrefix(header, "Bearer ") { + return "", ErrInvalidBearerToken } + token := strings.TrimPrefix(header, "Bearer ") + if token == "" { + return "", ErrInvalidBearerToken + } + return token, nil } // tenantIDFromContext returns the tenant ID from context (set by JWT middleware) or diff --git a/internal/apirouter/auth_middleware_test.go b/internal/apirouter/auth_middleware_test.go index fd409b56..dba561d5 100644 --- a/internal/apirouter/auth_middleware_test.go +++ b/internal/apirouter/auth_middleware_test.go @@ -15,27 +15,29 @@ import ( "github.com/stretchr/testify/require" ) -// retrieveErrorStore wraps a TenantStore and overrides RetrieveTenant -// to return a configurable error, simulating a store failure. -type retrieveErrorStore struct { - tenantstore.TenantStore - err error +// mockTenantRetriever implements apirouter.TenantRetriever for unit tests. +type mockTenantRetriever struct { + tenant *models.Tenant + err error } -func (s *retrieveErrorStore) RetrieveTenant(_ context.Context, _ string) (*models.Tenant, error) { - return nil, s.err +func (m *mockTenantRetriever) RetrieveTenant(_ context.Context, _ string) (*models.Tenant, error) { + return m.tenant, m.err +} + +// okHandler is a simple handler that returns 200 when reached. +var okHandler = func(c *gin.Context) { + c.Status(http.StatusOK) } func TestAuthMiddleware(t *testing.T) { - // okHandler is a simple handler that returns 200 when reached. - okHandler := func(c *gin.Context) { - c.Status(http.StatusOK) - } + existingTenant := &models.Tenant{ID: "t1"} + store := &mockTenantRetriever{tenant: existingTenant} - t.Run("AdminMiddleware", func(t *testing.T) { - t.Run("vpc mode grants admin without auth header", func(t *testing.T) { + t.Run("VPC mode", func(t *testing.T) { + t.Run("grants admin without auth header", func(t *testing.T) { r := gin.New() - r.GET("/test", apirouter.AdminMiddleware(""), okHandler) + r.GET("/test", apirouter.AuthMiddleware("", testJWTSecret, store, apirouter.AuthOptions{}), okHandler) req := httptest.NewRequest(http.MethodGet, "/test", nil) w := httptest.NewRecorder() @@ -44,9 +46,9 @@ func TestAuthMiddleware(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) }) - t.Run("vpc mode grants admin ignores auth header", func(t *testing.T) { + t.Run("grants admin ignores auth header", func(t *testing.T) { r := gin.New() - r.GET("/test", apirouter.AdminMiddleware(""), okHandler) + r.GET("/test", apirouter.AuthMiddleware("", testJWTSecret, store, apirouter.AuthOptions{}), okHandler) req := httptest.NewRequest(http.MethodGet, "/test", nil) req.Header.Set("Authorization", "Bearer wrong-key") @@ -56,168 +58,179 @@ func TestAuthMiddleware(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) }) - t.Run("valid api key returns 200", func(t *testing.T) { + t.Run("resolves tenant when RequireTenant", func(t *testing.T) { r := gin.New() - r.GET("/test", apirouter.AdminMiddleware(testAPIKey), okHandler) + r.GET("/test/:tenantID", apirouter.AuthMiddleware("", testJWTSecret, store, apirouter.AuthOptions{RequireTenant: true}), okHandler) - req := httptest.NewRequest(http.MethodGet, "/test", nil) - req.Header.Set("Authorization", "Bearer "+testAPIKey) + req := httptest.NewRequest(http.MethodGet, "/test/t1", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) }) + }) - t.Run("missing auth header returns 401", func(t *testing.T) { - r := gin.New() - r.GET("/test", apirouter.AdminMiddleware(testAPIKey), okHandler) + t.Run("missing auth header returns 401", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, store, apirouter.AuthOptions{}), okHandler) - req := httptest.NewRequest(http.MethodGet, "/test", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) - assert.Equal(t, http.StatusUnauthorized, w.Code) - }) + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) - t.Run("malformed bearer prefix returns 401", func(t *testing.T) { - r := gin.New() - r.GET("/test", apirouter.AdminMiddleware(testAPIKey), okHandler) + t.Run("malformed bearer prefix returns 401", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, store, apirouter.AuthOptions{}), okHandler) - req := httptest.NewRequest(http.MethodGet, "/test", nil) - req.Header.Set("Authorization", "Basic "+testAPIKey) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Basic "+testAPIKey) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) - assert.Equal(t, http.StatusUnauthorized, w.Code) - }) + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) - t.Run("empty bearer token returns 401", func(t *testing.T) { - r := gin.New() - r.GET("/test", apirouter.AdminMiddleware(testAPIKey), okHandler) + t.Run("empty bearer token returns 401", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, store, apirouter.AuthOptions{}), okHandler) - req := httptest.NewRequest(http.MethodGet, "/test", nil) - req.Header.Set("Authorization", "Bearer ") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer ") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) - assert.Equal(t, http.StatusUnauthorized, w.Code) - }) + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) - t.Run("wrong api key returns 401", func(t *testing.T) { - r := gin.New() - r.GET("/test", apirouter.AdminMiddleware(testAPIKey), okHandler) + t.Run("valid API key returns 200", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, store, apirouter.AuthOptions{}), okHandler) - req := httptest.NewRequest(http.MethodGet, "/test", nil) - req.Header.Set("Authorization", "Bearer wrong-key") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer "+testAPIKey) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) - assert.Equal(t, http.StatusUnauthorized, w.Code) - }) + assert.Equal(t, http.StatusOK, w.Code) }) - t.Run("AuthenticatedMiddleware", func(t *testing.T) { - t.Run("vpc mode grants admin without auth header", func(t *testing.T) { - r := gin.New() - r.GET("/test", apirouter.AuthenticatedMiddleware("", testJWTSecret), okHandler) + t.Run("invalid token not API key not valid JWT returns 401", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, store, apirouter.AuthOptions{}), okHandler) - req := httptest.NewRequest(http.MethodGet, "/test", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer not-a-valid-token") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - }) + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) - t.Run("valid api key returns 200", func(t *testing.T) { - r := gin.New() - r.GET("/test", apirouter.AuthenticatedMiddleware(testAPIKey, testJWTSecret), okHandler) + t.Run("valid JWT returns 200", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, store, apirouter.AuthOptions{}), okHandler) - req := httptest.NewRequest(http.MethodGet, "/test", nil) - req.Header.Set("Authorization", "Bearer "+testAPIKey) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) + token, err := apirouter.JWT.New(testJWTSecret, apirouter.JWTClaims{TenantID: "t1"}) + require.NoError(t, err) - assert.Equal(t, http.StatusOK, w.Code) - }) + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) - t.Run("valid jwt returns 200", func(t *testing.T) { - r := gin.New() - r.GET("/test", apirouter.AuthenticatedMiddleware(testAPIKey, testJWTSecret), okHandler) + assert.Equal(t, http.StatusOK, w.Code) + }) - token, err := apirouter.JWT.New(testJWTSecret, apirouter.JWTClaims{TenantID: "t1"}) - require.NoError(t, err) + t.Run("valid JWT on AdminOnly route returns 403", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, store, apirouter.AuthOptions{AdminOnly: true}), okHandler) - req := httptest.NewRequest(http.MethodGet, "/test", nil) - req.Header.Set("Authorization", "Bearer "+token) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) + token, err := apirouter.JWT.New(testJWTSecret, apirouter.JWTClaims{TenantID: "t1"}) + require.NoError(t, err) - assert.Equal(t, http.StatusOK, w.Code) - }) + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) - t.Run("invalid token neither api key nor jwt returns 401", func(t *testing.T) { - r := gin.New() - r.GET("/test", apirouter.AuthenticatedMiddleware(testAPIKey, testJWTSecret), okHandler) + assert.Equal(t, http.StatusForbidden, w.Code) + }) - req := httptest.NewRequest(http.MethodGet, "/test", nil) - req.Header.Set("Authorization", "Bearer not-a-valid-token") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) + t.Run("JWT wrong tenant param returns 403", func(t *testing.T) { + r := gin.New() + r.GET("/test/:tenantID", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, store, apirouter.AuthOptions{}), okHandler) - assert.Equal(t, http.StatusUnauthorized, w.Code) - }) + token, err := apirouter.JWT.New(testJWTSecret, apirouter.JWTClaims{TenantID: "t1"}) + require.NoError(t, err) - t.Run("missing auth header returns 401", func(t *testing.T) { - r := gin.New() - r.GET("/test", apirouter.AuthenticatedMiddleware(testAPIKey, testJWTSecret), okHandler) + req := httptest.NewRequest(http.MethodGet, "/test/t2", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) - req := httptest.NewRequest(http.MethodGet, "/test", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code) + }) - assert.Equal(t, http.StatusUnauthorized, w.Code) - }) + t.Run("JWT deleted tenant returns 401", func(t *testing.T) { + deletedStore := &mockTenantRetriever{err: tenantstore.ErrTenantDeleted} + r := gin.New() + r.GET("/test", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, deletedStore, apirouter.AuthOptions{}), okHandler) - t.Run("malformed bearer prefix returns 401", func(t *testing.T) { - r := gin.New() - r.GET("/test", apirouter.AuthenticatedMiddleware(testAPIKey, testJWTSecret), okHandler) + token, err := apirouter.JWT.New(testJWTSecret, apirouter.JWTClaims{TenantID: "t1"}) + require.NoError(t, err) - req := httptest.NewRequest(http.MethodGet, "/test", nil) - req.Header.Set("Authorization", "Token "+testAPIKey) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) - assert.Equal(t, http.StatusUnauthorized, w.Code) - }) + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) - t.Run("jwt for wrong tenant param returns 403", func(t *testing.T) { - r := gin.New() - r.GET("/test/:tenantID", apirouter.AuthenticatedMiddleware(testAPIKey, testJWTSecret), okHandler) + t.Run("JWT missing tenant returns 401", func(t *testing.T) { + nilStore := &mockTenantRetriever{tenant: nil} + r := gin.New() + r.GET("/test", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, nilStore, apirouter.AuthOptions{}), okHandler) - token, err := apirouter.JWT.New(testJWTSecret, apirouter.JWTClaims{TenantID: "t1"}) - require.NoError(t, err) + token, err := apirouter.JWT.New(testJWTSecret, apirouter.JWTClaims{TenantID: "t1"}) + require.NoError(t, err) - req := httptest.NewRequest(http.MethodGet, "/test/t2", nil) - req.Header.Set("Authorization", "Bearer "+token) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) - assert.Equal(t, http.StatusForbidden, w.Code) - }) + assert.Equal(t, http.StatusUnauthorized, w.Code) }) - t.Run("resolveTenantMiddleware", func(t *testing.T) { - t.Run("store error returns 500", func(t *testing.T) { - store := &retrieveErrorStore{ - TenantStore: tenantstore.NewMemTenantStore(), - err: errors.New("database connection failed"), - } - h := newAPITest(t, withTenantStore(store)) + t.Run("RequireTenant admin missing tenant returns 404", func(t *testing.T) { + nilStore := &mockTenantRetriever{tenant: nil} + r := gin.New() + r.Use(apirouter.ErrorHandlerMiddleware()) + r.GET("/test/:tenantID", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, nilStore, apirouter.AuthOptions{RequireTenant: true}), okHandler) - req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1", nil) - resp := h.do(h.withAPIKey(req)) + req := httptest.NewRequest(http.MethodGet, "/test/t1", nil) + req.Header.Set("Authorization", "Bearer "+testAPIKey) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) - require.Equal(t, http.StatusInternalServerError, resp.Code) - }) + assert.Equal(t, http.StatusNotFound, w.Code) + }) + + t.Run("store error returns 500", func(t *testing.T) { + errStore := &mockTenantRetriever{err: errors.New("database connection failed")} + r := gin.New() + r.Use(apirouter.ErrorHandlerMiddleware()) + r.GET("/test/:tenantID", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, errStore, apirouter.AuthOptions{RequireTenant: true}), okHandler) + + req := httptest.NewRequest(http.MethodGet, "/test/t1", nil) + req.Header.Set("Authorization", "Bearer "+testAPIKey) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) }) } diff --git a/internal/apirouter/errorhandler_middleware.go b/internal/apirouter/errorhandler_middleware.go index ff006ced..7561fecc 100644 --- a/internal/apirouter/errorhandler_middleware.go +++ b/internal/apirouter/errorhandler_middleware.go @@ -115,6 +115,8 @@ func formatValidationError(field, tag, param string) string { return fmt.Sprintf("%s must be less than %s", field, param) case "lte": return fmt.Sprintf("%s must be less than or equal to %s", field, param) + case "forbidden": + return fmt.Sprintf("%s is forbidden", field) default: if param != "" { return fmt.Sprintf("%s failed %s=%s validation", field, tag, param) diff --git a/internal/apirouter/errorhandler_middleware_test.go b/internal/apirouter/errorhandler_middleware_test.go index 6fb44f20..4c127c50 100644 --- a/internal/apirouter/errorhandler_middleware_test.go +++ b/internal/apirouter/errorhandler_middleware_test.go @@ -181,6 +181,31 @@ func TestErrorResponse_NotFoundFormat(t *testing.T) { assert.Equal(t, "tenant not found", response["message"]) } +func TestFormatValidationError_ForbiddenTag(t *testing.T) { + t.Parallel() + + type testInput struct { + Role string `validate:"forbidden"` + } + + validate := validator.New() + // Register a custom "forbidden" validator that always fails, so we can test the message. + validate.RegisterValidation("forbidden", func(fl validator.FieldLevel) bool { + return false + }) + + input := testInput{Role: "superadmin"} + err := validate.Struct(input) + require.Error(t, err) + + var errorResponse apirouter.ErrorResponse + errorResponse.Parse(err) + + messages, ok := errorResponse.Data.([]string) + require.True(t, ok, "Data should be []string, got %T", errorResponse.Data) + assert.Contains(t, messages, "role is forbidden") +} + func TestErrorResponse_InternalServerErrorFormat(t *testing.T) { t.Parallel() diff --git a/internal/apirouter/publish_handlers_test.go b/internal/apirouter/publish_handlers_test.go index 81f73ec1..ba02c407 100644 --- a/internal/apirouter/publish_handlers_test.go +++ b/internal/apirouter/publish_handlers_test.go @@ -28,7 +28,7 @@ func TestAPI_Publish(t *testing.T) { require.Equal(t, http.StatusUnauthorized, resp.Code) }) - t.Run("jwt returns 401", func(t *testing.T) { + t.Run("jwt returns 403", func(t *testing.T) { h := newAPITest(t) h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) @@ -37,7 +37,7 @@ func TestAPI_Publish(t *testing.T) { }) resp := h.do(h.withJWT(req, "t1")) - require.Equal(t, http.StatusUnauthorized, resp.Code) + require.Equal(t, http.StatusForbidden, resp.Code) }) t.Run("api key succeeds", func(t *testing.T) { diff --git a/internal/apirouter/router.go b/internal/apirouter/router.go index d263eb17..f9b1e4d2 100644 --- a/internal/apirouter/router.go +++ b/internal/apirouter/router.go @@ -20,19 +20,11 @@ import ( "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" ) -type AuthMode string - -const ( - AuthAdmin AuthMode = "admin" - AuthAuthenticated AuthMode = "authenticated" - AuthPublic AuthMode = "public" -) - type RouteDefinition struct { Method string Path string Handler gin.HandlerFunc - AuthMode AuthMode + AdminOnly bool RequireTenant bool Middlewares []gin.HandlerFunc } @@ -90,22 +82,10 @@ func registerRoutes(router *gin.RouterGroup, cfg RouterConfig, tenantRetriever T func buildMiddlewareChain(cfg RouterConfig, tenantRetriever TenantRetriever, def RouteDefinition) []gin.HandlerFunc { chain := make([]gin.HandlerFunc, 0) - // Add auth middleware based on mode - switch def.AuthMode { - case AuthAdmin: - chain = append(chain, AdminMiddleware(cfg.APIKey)) - if def.RequireTenant { - chain = append(chain, resolveTenantMiddleware(tenantRetriever, true)) - } - case AuthAuthenticated: - chain = append(chain, AuthenticatedMiddleware(cfg.APIKey, cfg.JWTSecret)) - // Always add tenant resolution for authenticated routes: - // - RequireTenant routes: resolve from param, 404 if missing - // - JWT users on non-RequireTenant routes: resolve from context tenantID - chain = append(chain, resolveTenantMiddleware(tenantRetriever, def.RequireTenant)) - case AuthPublic: - // no auth middleware - } + chain = append(chain, AuthMiddleware(cfg.APIKey, cfg.JWTSecret, tenantRetriever, AuthOptions{ + AdminOnly: def.AdminOnly, + RequireTenant: def.RequireTenant, + })) // Add custom middlewares chain = append(chain, def.Middlewares...) @@ -165,40 +145,40 @@ func NewRouter(cfg RouterConfig, deps RouterDeps) http.Handler { routes := []RouteDefinition{ // Schemas & Topics - {Method: http.MethodGet, Path: "/destination-types", Handler: destinationHandlers.ListProviderMetadata, AuthMode: AuthAuthenticated}, - {Method: http.MethodGet, Path: "/destination-types/:type", Handler: destinationHandlers.RetrieveProviderMetadata, AuthMode: AuthAuthenticated}, - {Method: http.MethodGet, Path: "/topics", Handler: topicHandlers.List, AuthMode: AuthAuthenticated}, + {Method: http.MethodGet, Path: "/destination-types", Handler: destinationHandlers.ListProviderMetadata}, + {Method: http.MethodGet, Path: "/destination-types/:type", Handler: destinationHandlers.RetrieveProviderMetadata}, + {Method: http.MethodGet, Path: "/topics", Handler: topicHandlers.List}, // Publish / Retry - {Method: http.MethodPost, Path: "/publish", Handler: publishHandlers.Ingest, AuthMode: AuthAdmin}, - {Method: http.MethodPost, Path: "/retry", Handler: retryHandlers.Retry, AuthMode: AuthAuthenticated}, + {Method: http.MethodPost, Path: "/publish", Handler: publishHandlers.Ingest, AdminOnly: true}, + {Method: http.MethodPost, Path: "/retry", Handler: retryHandlers.Retry}, // Tenants - {Method: http.MethodGet, Path: "/tenants", Handler: tenantHandlers.List, AuthMode: AuthAuthenticated}, - {Method: http.MethodPut, Path: "/tenants/:tenantID", Handler: tenantHandlers.Upsert, AuthMode: AuthAuthenticated}, - {Method: http.MethodGet, Path: "/tenants/:tenantID", Handler: tenantHandlers.Retrieve, AuthMode: AuthAuthenticated, RequireTenant: true}, - {Method: http.MethodDelete, Path: "/tenants/:tenantID", Handler: tenantHandlers.Delete, AuthMode: AuthAuthenticated, RequireTenant: true}, - {Method: http.MethodGet, Path: "/tenants/:tenantID/token", Handler: tenantHandlers.RetrieveToken, AuthMode: AuthAdmin, RequireTenant: true}, - {Method: http.MethodGet, Path: "/tenants/:tenantID/portal", Handler: tenantHandlers.RetrievePortal, AuthMode: AuthAdmin, RequireTenant: true}, + {Method: http.MethodGet, Path: "/tenants", Handler: tenantHandlers.List}, + {Method: http.MethodPut, Path: "/tenants/:tenantID", Handler: tenantHandlers.Upsert}, + {Method: http.MethodGet, Path: "/tenants/:tenantID", Handler: tenantHandlers.Retrieve, RequireTenant: true}, + {Method: http.MethodDelete, Path: "/tenants/:tenantID", Handler: tenantHandlers.Delete, RequireTenant: true}, + {Method: http.MethodGet, Path: "/tenants/:tenantID/token", Handler: tenantHandlers.RetrieveToken, AdminOnly: true, RequireTenant: true}, + {Method: http.MethodGet, Path: "/tenants/:tenantID/portal", Handler: tenantHandlers.RetrievePortal, AdminOnly: true, RequireTenant: true}, // Destinations - {Method: http.MethodGet, Path: "/tenants/:tenantID/destinations", Handler: destinationHandlers.List, AuthMode: AuthAuthenticated, RequireTenant: true}, - {Method: http.MethodPost, Path: "/tenants/:tenantID/destinations", Handler: destinationHandlers.Create, AuthMode: AuthAuthenticated, RequireTenant: true}, - {Method: http.MethodGet, Path: "/tenants/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Retrieve, AuthMode: AuthAuthenticated, RequireTenant: true}, - {Method: http.MethodPatch, Path: "/tenants/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Update, AuthMode: AuthAuthenticated, RequireTenant: true}, - {Method: http.MethodDelete, Path: "/tenants/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Delete, AuthMode: AuthAuthenticated, RequireTenant: true}, - {Method: http.MethodPut, Path: "/tenants/:tenantID/destinations/:destinationID/enable", Handler: destinationHandlers.Enable, AuthMode: AuthAuthenticated, RequireTenant: true}, - {Method: http.MethodPut, Path: "/tenants/:tenantID/destinations/:destinationID/disable", Handler: destinationHandlers.Disable, AuthMode: AuthAuthenticated, RequireTenant: true}, - {Method: http.MethodGet, Path: "/tenants/:tenantID/destinations/:destinationID/attempts", Handler: logHandlers.ListDestinationAttempts, AuthMode: AuthAuthenticated, RequireTenant: true}, - {Method: http.MethodGet, Path: "/tenants/:tenantID/destinations/:destinationID/attempts/:attemptID", Handler: logHandlers.RetrieveAttempt, AuthMode: AuthAuthenticated, RequireTenant: true}, + {Method: http.MethodGet, Path: "/tenants/:tenantID/destinations", Handler: destinationHandlers.List, RequireTenant: true}, + {Method: http.MethodPost, Path: "/tenants/:tenantID/destinations", Handler: destinationHandlers.Create, RequireTenant: true}, + {Method: http.MethodGet, Path: "/tenants/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Retrieve, RequireTenant: true}, + {Method: http.MethodPatch, Path: "/tenants/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Update, RequireTenant: true}, + {Method: http.MethodDelete, Path: "/tenants/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Delete, RequireTenant: true}, + {Method: http.MethodPut, Path: "/tenants/:tenantID/destinations/:destinationID/enable", Handler: destinationHandlers.Enable, RequireTenant: true}, + {Method: http.MethodPut, Path: "/tenants/:tenantID/destinations/:destinationID/disable", Handler: destinationHandlers.Disable, RequireTenant: true}, + {Method: http.MethodGet, Path: "/tenants/:tenantID/destinations/:destinationID/attempts", Handler: logHandlers.ListDestinationAttempts, RequireTenant: true}, + {Method: http.MethodGet, Path: "/tenants/:tenantID/destinations/:destinationID/attempts/:attemptID", Handler: logHandlers.RetrieveAttempt, RequireTenant: true}, // Events - {Method: http.MethodGet, Path: "/events", Handler: logHandlers.ListEvents, AuthMode: AuthAuthenticated}, - {Method: http.MethodGet, Path: "/events/:eventID", Handler: logHandlers.RetrieveEvent, AuthMode: AuthAuthenticated}, + {Method: http.MethodGet, Path: "/events", Handler: logHandlers.ListEvents}, + {Method: http.MethodGet, Path: "/events/:eventID", Handler: logHandlers.RetrieveEvent}, // Attempts - {Method: http.MethodGet, Path: "/attempts", Handler: logHandlers.ListAttempts, AuthMode: AuthAuthenticated}, - {Method: http.MethodGet, Path: "/attempts/:attemptID", Handler: logHandlers.RetrieveAttempt, AuthMode: AuthAuthenticated}, + {Method: http.MethodGet, Path: "/attempts", Handler: logHandlers.ListAttempts}, + {Method: http.MethodGet, Path: "/attempts/:attemptID", Handler: logHandlers.RetrieveAttempt}, } registerRoutes(apiRouter, cfg, deps.TenantStore, routes) diff --git a/internal/apirouter/tenant_handlers_test.go b/internal/apirouter/tenant_handlers_test.go index c6a9f4b3..2c46bff3 100644 --- a/internal/apirouter/tenant_handlers_test.go +++ b/internal/apirouter/tenant_handlers_test.go @@ -82,7 +82,7 @@ func TestAPI_Tenants(t *testing.T) { t.Run("jwt nonexistent tenant returns 401", func(t *testing.T) { h := newAPITest(t) - // t1 doesn't exist — resolveTenantMiddleware rejects before handler runs + // t1 doesn't exist — AuthMiddleware rejects before handler runs req := httptest.NewRequest(http.MethodPut, "/api/v1/tenants/t1", nil) resp := h.do(h.withJWT(req, "t1")) @@ -421,7 +421,7 @@ func TestAPI_Tenants(t *testing.T) { require.Equal(t, http.StatusNotFound, resp.Code) }) - t.Run("jwt returns 401", func(t *testing.T) { + t.Run("jwt returns 403", func(t *testing.T) { h := newAPITest(t) h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) @@ -429,7 +429,7 @@ func TestAPI_Tenants(t *testing.T) { resp := h.do(h.withJWT(req, "t1")) // Token endpoint is admin-only; JWT auth should be rejected - require.Equal(t, http.StatusUnauthorized, resp.Code) + require.Equal(t, http.StatusForbidden, resp.Code) }) t.Run("no auth returns 401", func(t *testing.T) { @@ -511,7 +511,7 @@ func TestAPI_Tenants(t *testing.T) { require.Equal(t, http.StatusNotFound, resp.Code) }) - t.Run("jwt returns 401", func(t *testing.T) { + t.Run("jwt returns 403", func(t *testing.T) { h := newAPITest(t) h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) @@ -519,7 +519,7 @@ func TestAPI_Tenants(t *testing.T) { resp := h.do(h.withJWT(req, "t1")) // Portal endpoint is admin-only; JWT auth should be rejected - require.Equal(t, http.StatusUnauthorized, resp.Code) + require.Equal(t, http.StatusForbidden, resp.Code) }) t.Run("no auth returns 401", func(t *testing.T) { From 0dbc058fabeffd395780f21670615b7136cfd3cd Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sun, 1 Feb 2026 02:38:30 +0700 Subject: [PATCH 24/34] chore: rename path names to snake_case --- internal/apirouter/auth_middleware.go | 12 ++++---- internal/apirouter/auth_middleware_test.go | 8 +++--- internal/apirouter/destination_handlers.go | 8 +++--- internal/apirouter/log_handlers.go | 14 +++++----- internal/apirouter/router.go | 32 +++++++++++----------- internal/apirouter/tenant_handlers.go | 2 +- 6 files changed, 38 insertions(+), 38 deletions(-) diff --git a/internal/apirouter/auth_middleware.go b/internal/apirouter/auth_middleware.go index 97c1f8a5..d556a0e9 100644 --- a/internal/apirouter/auth_middleware.go +++ b/internal/apirouter/auth_middleware.go @@ -46,7 +46,7 @@ type AuthOptions struct { // 3. token == apiKey → admin, resolve tenant if RequireTenant, done. // 4. JWT.Extract(token) → 401 if invalid. // 5. AdminOnly? → 403. -// 6. :tenantID param mismatch? → 403. +// 6. :tenant_id param mismatch? → 403. // 7. Set tenantID + RoleTenant, always resolve tenant for JWT → 401 if missing/deleted. func AuthMiddleware(apiKey, jwtSecret string, tenantRetriever TenantRetriever, opts AuthOptions) gin.HandlerFunc { // VPC mode — no API key configured, everything is admin. @@ -97,8 +97,8 @@ func AuthMiddleware(apiKey, jwtSecret string, tenantRetriever TenantRetriever, o return } - // 6. tenantID param mismatch - if paramTenantID := c.Param("tenantID"); paramTenantID != "" && paramTenantID != claims.TenantID { + // 6. tenant_id param mismatch + if paramTenantID := c.Param("tenant_id"); paramTenantID != "" && paramTenantID != claims.TenantID { c.AbortWithStatus(http.StatusForbidden) return } @@ -170,13 +170,13 @@ func validateAuthHeader(c *gin.Context) (string, error) { } // tenantIDFromContext returns the tenant ID from context (set by JWT middleware) or -// falls back to the :tenantID URL param (for API key auth on tenant-scoped routes). -// Returns empty string when using API key auth on a route with no :tenantID in path. +// falls back to the :tenant_id URL param (for API key auth on tenant-scoped routes). +// Returns empty string when using API key auth on a route with no :tenant_id in path. func tenantIDFromContext(c *gin.Context) string { if id, ok := c.Get("tenantID"); ok { return id.(string) } - return c.Param("tenantID") + return c.Param("tenant_id") } // resolveTenantIDFilter returns the effective tenant ID for log queries. diff --git a/internal/apirouter/auth_middleware_test.go b/internal/apirouter/auth_middleware_test.go index dba561d5..9b808085 100644 --- a/internal/apirouter/auth_middleware_test.go +++ b/internal/apirouter/auth_middleware_test.go @@ -60,7 +60,7 @@ func TestAuthMiddleware(t *testing.T) { t.Run("resolves tenant when RequireTenant", func(t *testing.T) { r := gin.New() - r.GET("/test/:tenantID", apirouter.AuthMiddleware("", testJWTSecret, store, apirouter.AuthOptions{RequireTenant: true}), okHandler) + r.GET("/test/:tenant_id", apirouter.AuthMiddleware("", testJWTSecret, store, apirouter.AuthOptions{RequireTenant: true}), okHandler) req := httptest.NewRequest(http.MethodGet, "/test/t1", nil) w := httptest.NewRecorder() @@ -161,7 +161,7 @@ func TestAuthMiddleware(t *testing.T) { t.Run("JWT wrong tenant param returns 403", func(t *testing.T) { r := gin.New() - r.GET("/test/:tenantID", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, store, apirouter.AuthOptions{}), okHandler) + r.GET("/test/:tenant_id", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, store, apirouter.AuthOptions{}), okHandler) token, err := apirouter.JWT.New(testJWTSecret, apirouter.JWTClaims{TenantID: "t1"}) require.NoError(t, err) @@ -210,7 +210,7 @@ func TestAuthMiddleware(t *testing.T) { nilStore := &mockTenantRetriever{tenant: nil} r := gin.New() r.Use(apirouter.ErrorHandlerMiddleware()) - r.GET("/test/:tenantID", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, nilStore, apirouter.AuthOptions{RequireTenant: true}), okHandler) + r.GET("/test/:tenant_id", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, nilStore, apirouter.AuthOptions{RequireTenant: true}), okHandler) req := httptest.NewRequest(http.MethodGet, "/test/t1", nil) req.Header.Set("Authorization", "Bearer "+testAPIKey) @@ -224,7 +224,7 @@ func TestAuthMiddleware(t *testing.T) { errStore := &mockTenantRetriever{err: errors.New("database connection failed")} r := gin.New() r.Use(apirouter.ErrorHandlerMiddleware()) - r.GET("/test/:tenantID", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, errStore, apirouter.AuthOptions{RequireTenant: true}), okHandler) + r.GET("/test/:tenant_id", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, errStore, apirouter.AuthOptions{RequireTenant: true}), okHandler) req := httptest.NewRequest(http.MethodGet, "/test/t1", nil) req.Header.Set("Authorization", "Bearer "+testAPIKey) diff --git a/internal/apirouter/destination_handlers.go b/internal/apirouter/destination_handlers.go index 18ac9f56..fdb6b55f 100644 --- a/internal/apirouter/destination_handlers.go +++ b/internal/apirouter/destination_handlers.go @@ -107,7 +107,7 @@ func (h *DestinationHandlers) Create(c *gin.Context) { func (h *DestinationHandlers) Retrieve(c *gin.Context) { tenant := mustTenantFromContext(c) - destination := h.mustRetrieveDestination(c, tenant.ID, c.Param("destinationID")) + destination := h.mustRetrieveDestination(c, tenant.ID, c.Param("destination_id")) if destination == nil { return } @@ -130,7 +130,7 @@ func (h *DestinationHandlers) Update(c *gin.Context) { // Retrieve destination. tenant := mustTenantFromContext(c) - originalDestination := h.mustRetrieveDestination(c, tenant.ID, c.Param("destinationID")) + originalDestination := h.mustRetrieveDestination(c, tenant.ID, c.Param("destination_id")) if originalDestination == nil { return } @@ -200,7 +200,7 @@ func (h *DestinationHandlers) Update(c *gin.Context) { func (h *DestinationHandlers) Delete(c *gin.Context) { tenant := mustTenantFromContext(c) - destination := h.mustRetrieveDestination(c, tenant.ID, c.Param("destinationID")) + destination := h.mustRetrieveDestination(c, tenant.ID, c.Param("destination_id")) if destination == nil { return } @@ -242,7 +242,7 @@ func (h *DestinationHandlers) RetrieveProviderMetadata(c *gin.Context) { func (h *DestinationHandlers) setDisabilityHandler(c *gin.Context, disabled bool) { tenant := mustTenantFromContext(c) - destination := h.mustRetrieveDestination(c, tenant.ID, c.Param("destinationID")) + destination := h.mustRetrieveDestination(c, tenant.ID, c.Param("destination_id")) if destination == nil { return } diff --git a/internal/apirouter/log_handlers.go b/internal/apirouter/log_handlers.go index a8cc23de..f50b410d 100644 --- a/internal/apirouter/log_handlers.go +++ b/internal/apirouter/log_handlers.go @@ -195,11 +195,11 @@ func (h *LogHandlers) ListAttempts(c *gin.Context) { h.listAttemptsInternal(c, tenantID, "") } -// ListDestinationAttempts handles GET /:tenantID/destinations/:destinationID/attempts +// ListDestinationAttempts handles GET /:tenant_id/destinations/:destination_id/attempts // Same as ListAttempts but scoped to a specific destination via URL param. func (h *LogHandlers) ListDestinationAttempts(c *gin.Context) { tenant := mustTenantFromContext(c) - destinationID := c.Param("destinationID") + destinationID := c.Param("destination_id") h.listAttemptsInternal(c, tenant.ID, destinationID) } @@ -296,14 +296,14 @@ func (h *LogHandlers) listAttemptsInternal(c *gin.Context, tenantID string, dest }) } -// RetrieveEvent handles GET /events/:eventID +// RetrieveEvent handles GET /events/:event_id func (h *LogHandlers) RetrieveEvent(c *gin.Context) { // Authz: JWT users can only query their own tenant's events tenantID, ok := resolveTenantIDFilter(c) if !ok { return } - eventID := c.Param("eventID") + eventID := c.Param("event_id") event, err := h.logStore.RetrieveEvent(c.Request.Context(), logstore.RetrieveEventRequest{ TenantID: tenantID, EventID: eventID, @@ -326,14 +326,14 @@ func (h *LogHandlers) RetrieveEvent(c *gin.Context) { }) } -// RetrieveAttempt handles GET /attempts/:attemptID +// RetrieveAttempt handles GET /attempts/:attempt_id func (h *LogHandlers) RetrieveAttempt(c *gin.Context) { // Authz: JWT users can only query their own tenant's attempts tenantID, ok := resolveTenantIDFilter(c) if !ok { return } - attemptID := c.Param("attemptID") + attemptID := c.Param("attempt_id") attemptRecord, err := h.logStore.RetrieveAttempt(c.Request.Context(), logstore.RetrieveAttemptRequest{ TenantID: tenantID, @@ -350,7 +350,7 @@ func (h *LogHandlers) RetrieveAttempt(c *gin.Context) { // Authz: when accessed via a destination-scoped route, verify the attempt // belongs to the destination in the path. - if destinationID := c.Param("destinationID"); destinationID != "" { + if destinationID := c.Param("destination_id"); destinationID != "" { if attemptRecord.Attempt.DestinationID != destinationID { AbortWithError(c, http.StatusNotFound, NewErrNotFound("attempt")) return diff --git a/internal/apirouter/router.go b/internal/apirouter/router.go index f9b1e4d2..e2e61e1e 100644 --- a/internal/apirouter/router.go +++ b/internal/apirouter/router.go @@ -155,30 +155,30 @@ func NewRouter(cfg RouterConfig, deps RouterDeps) http.Handler { // Tenants {Method: http.MethodGet, Path: "/tenants", Handler: tenantHandlers.List}, - {Method: http.MethodPut, Path: "/tenants/:tenantID", Handler: tenantHandlers.Upsert}, - {Method: http.MethodGet, Path: "/tenants/:tenantID", Handler: tenantHandlers.Retrieve, RequireTenant: true}, - {Method: http.MethodDelete, Path: "/tenants/:tenantID", Handler: tenantHandlers.Delete, RequireTenant: true}, - {Method: http.MethodGet, Path: "/tenants/:tenantID/token", Handler: tenantHandlers.RetrieveToken, AdminOnly: true, RequireTenant: true}, - {Method: http.MethodGet, Path: "/tenants/:tenantID/portal", Handler: tenantHandlers.RetrievePortal, AdminOnly: true, RequireTenant: true}, + {Method: http.MethodPut, Path: "/tenants/:tenant_id", Handler: tenantHandlers.Upsert}, + {Method: http.MethodGet, Path: "/tenants/:tenant_id", Handler: tenantHandlers.Retrieve, RequireTenant: true}, + {Method: http.MethodDelete, Path: "/tenants/:tenant_id", Handler: tenantHandlers.Delete, RequireTenant: true}, + {Method: http.MethodGet, Path: "/tenants/:tenant_id/token", Handler: tenantHandlers.RetrieveToken, AdminOnly: true, RequireTenant: true}, + {Method: http.MethodGet, Path: "/tenants/:tenant_id/portal", Handler: tenantHandlers.RetrievePortal, AdminOnly: true, RequireTenant: true}, // Destinations - {Method: http.MethodGet, Path: "/tenants/:tenantID/destinations", Handler: destinationHandlers.List, RequireTenant: true}, - {Method: http.MethodPost, Path: "/tenants/:tenantID/destinations", Handler: destinationHandlers.Create, RequireTenant: true}, - {Method: http.MethodGet, Path: "/tenants/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Retrieve, RequireTenant: true}, - {Method: http.MethodPatch, Path: "/tenants/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Update, RequireTenant: true}, - {Method: http.MethodDelete, Path: "/tenants/:tenantID/destinations/:destinationID", Handler: destinationHandlers.Delete, RequireTenant: true}, - {Method: http.MethodPut, Path: "/tenants/:tenantID/destinations/:destinationID/enable", Handler: destinationHandlers.Enable, RequireTenant: true}, - {Method: http.MethodPut, Path: "/tenants/:tenantID/destinations/:destinationID/disable", Handler: destinationHandlers.Disable, RequireTenant: true}, - {Method: http.MethodGet, Path: "/tenants/:tenantID/destinations/:destinationID/attempts", Handler: logHandlers.ListDestinationAttempts, RequireTenant: true}, - {Method: http.MethodGet, Path: "/tenants/:tenantID/destinations/:destinationID/attempts/:attemptID", Handler: logHandlers.RetrieveAttempt, RequireTenant: true}, + {Method: http.MethodGet, Path: "/tenants/:tenant_id/destinations", Handler: destinationHandlers.List, RequireTenant: true}, + {Method: http.MethodPost, Path: "/tenants/:tenant_id/destinations", Handler: destinationHandlers.Create, RequireTenant: true}, + {Method: http.MethodGet, Path: "/tenants/:tenant_id/destinations/:destination_id", Handler: destinationHandlers.Retrieve, RequireTenant: true}, + {Method: http.MethodPatch, Path: "/tenants/:tenant_id/destinations/:destination_id", Handler: destinationHandlers.Update, RequireTenant: true}, + {Method: http.MethodDelete, Path: "/tenants/:tenant_id/destinations/:destination_id", Handler: destinationHandlers.Delete, RequireTenant: true}, + {Method: http.MethodPut, Path: "/tenants/:tenant_id/destinations/:destination_id/enable", Handler: destinationHandlers.Enable, RequireTenant: true}, + {Method: http.MethodPut, Path: "/tenants/:tenant_id/destinations/:destination_id/disable", Handler: destinationHandlers.Disable, RequireTenant: true}, + {Method: http.MethodGet, Path: "/tenants/:tenant_id/destinations/:destination_id/attempts", Handler: logHandlers.ListDestinationAttempts, RequireTenant: true}, + {Method: http.MethodGet, Path: "/tenants/:tenant_id/destinations/:destination_id/attempts/:attempt_id", Handler: logHandlers.RetrieveAttempt, RequireTenant: true}, // Events {Method: http.MethodGet, Path: "/events", Handler: logHandlers.ListEvents}, - {Method: http.MethodGet, Path: "/events/:eventID", Handler: logHandlers.RetrieveEvent}, + {Method: http.MethodGet, Path: "/events/:event_id", Handler: logHandlers.RetrieveEvent}, // Attempts {Method: http.MethodGet, Path: "/attempts", Handler: logHandlers.ListAttempts}, - {Method: http.MethodGet, Path: "/attempts/:attemptID", Handler: logHandlers.RetrieveAttempt}, + {Method: http.MethodGet, Path: "/attempts/:attempt_id", Handler: logHandlers.RetrieveAttempt}, } registerRoutes(apiRouter, cfg, deps.TenantStore, routes) diff --git a/internal/apirouter/tenant_handlers.go b/internal/apirouter/tenant_handlers.go index ce0f4f57..907ab564 100644 --- a/internal/apirouter/tenant_handlers.go +++ b/internal/apirouter/tenant_handlers.go @@ -38,7 +38,7 @@ func NewTenantHandlers( } func (h *TenantHandlers) Upsert(c *gin.Context) { - tenantID := c.Param("tenantID") + tenantID := c.Param("tenant_id") // Parse request body for metadata var input struct { From ef739764442bb41af87a186cf619bfba3b20ff37 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sun, 1 Feb 2026 03:27:22 +0700 Subject: [PATCH 25/34] test: tenant metadata conversion --- internal/apirouter/tenant_handlers_test.go | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/internal/apirouter/tenant_handlers_test.go b/internal/apirouter/tenant_handlers_test.go index 2c46bff3..33da87a3 100644 --- a/internal/apirouter/tenant_handlers_test.go +++ b/internal/apirouter/tenant_handlers_test.go @@ -64,6 +64,33 @@ func TestAPI_Tenants(t *testing.T) { assert.Equal(t, models.Metadata{"env": "prod"}, tenant.Metadata) }) + t.Run("metadata auto-converts non-string values", func(t *testing.T) { + h := newAPITest(t) + + req := h.jsonReq(http.MethodPut, "/api/v1/tenants/t1", map[string]any{ + "metadata": map[string]any{ + "count": 42, + "enabled": true, + "ratio": 3.14, + "empty": nil, + "nested": map[string]any{"key": "val"}, + }, + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusCreated, resp.Code) + + tenant, err := h.tenantStore.RetrieveTenant(t.Context(), "t1") + require.NoError(t, err) + assert.Equal(t, models.Metadata{ + "count": "42", + "enabled": "true", + "ratio": "3.14", + "empty": "", + "nested": `{"key":"val"}`, + }, tenant.Metadata) + }) + t.Run("jwt updates own tenant", func(t *testing.T) { h := newAPITest(t) h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) From d37ac622586fac75b66b8fc212736f4032fbbd29 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sun, 1 Feb 2026 03:33:32 +0700 Subject: [PATCH 26/34] test: clean up e2e tests --- cmd/e2e/api_test.go | 2182 ----------------------------------- cmd/e2e/destwebhook_test.go | 17 +- cmd/e2e/log_test.go | 435 +------ 3 files changed, 51 insertions(+), 2583 deletions(-) diff --git a/cmd/e2e/api_test.go b/cmd/e2e/api_test.go index 33fc719a..b4ccd471 100644 --- a/cmd/e2e/api_test.go +++ b/cmd/e2e/api_test.go @@ -1,16 +1,9 @@ package e2e_test import ( - "bytes" - "fmt" "net/http" - "testing" - "time" "github.com/hookdeck/outpost/cmd/e2e/httpclient" - "github.com/hookdeck/outpost/internal/idgen" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func (suite *basicSuite) TestHealthzAPI() { @@ -45,2181 +38,6 @@ func (suite *basicSuite) TestHealthzAPI() { suite.RunAPITests(suite.T(), tests) } -func (suite *basicSuite) TestTenantsAPI() { - tenantID := idgen.String() - sampleDestinationID := idgen.Destination() - tests := []APITest{ - { - Name: "GET /tenants/:tenantID without auth header", - Request: httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusUnauthorized, - }, - }, - }, - { - Name: "GET /tenants/:tenantID without tenant", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusNotFound, - }, - }, - }, - { - Name: "PUT /tenants/:tenantID without auth header", - Request: httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusUnauthorized, - }, - }, - }, - { - Name: "PUT /tenants/:tenantID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - Body: map[string]interface{}{ - "id": tenantID, - "destinations_count": 0, - "topics": []string{}, - }, - }, - }, - }, - { - Name: "GET /tenants/:tenantID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "id": tenantID, - "destinations_count": 0, - "topics": []string{}, - }, - }, - }, - }, - { - Name: "PUT /tenants/:tenantID again", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "id": tenantID, - "destinations_count": 0, - "topics": []string{}, - }, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "id": sampleDestinationID, - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "GET /tenants/:tenantID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "id": tenantID, - "destinations_count": 1, - "topics": []string{"*"}, - }, - }, - }, - }, - { - Name: "PATCH /tenants/:tenantID/destinations/:destinationID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + sampleDestinationID, - Body: map[string]interface{}{ - "topics": []string{suite.config.Topics[0]}, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "GET /tenants/:tenantID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "id": tenantID, - "destinations_count": 1, - "topics": []string{suite.config.Topics[0]}, - }, - }, - }, - }, - { - Name: "DELETE /tenants/:tenantID/destinations/:destinationID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodDELETE, - Path: "/tenants/" + tenantID + "/destinations/" + sampleDestinationID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "GET /tenants/:tenantID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "id": tenantID, - "destinations_count": 0, - "topics": []string{}, - }, - }, - }, - }, - { - Name: "DELETE /tenants/:tenantID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodDELETE, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "GET /tenants/:tenantID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusNotFound, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusNotFound, - }, - }, - }, - { - Name: "PUT /tenants/:tenantID should override deleted tenant", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - Body: map[string]interface{}{ - "id": tenantID, - "destinations_count": 0, - "topics": []string{}, - }, - }, - }, - }, - // Metadata tests - { - Name: "PUT /tenants/:tenantID with metadata", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - Body: map[string]interface{}{ - "metadata": map[string]interface{}{ - "environment": "production", - "team": "platform", - "region": "us-east-1", - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "id": tenantID, - "metadata": map[string]interface{}{ - "environment": "production", - "team": "platform", - "region": "us-east-1", - }, - }, - }, - }, - }, - { - Name: "GET /tenants/:tenantID retrieves metadata", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "id": tenantID, - "metadata": map[string]interface{}{ - "environment": "production", - "team": "platform", - "region": "us-east-1", - }, - }, - }, - }, - }, - { - Name: "PUT /tenants/:tenantID replaces metadata (full replacement)", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - Body: map[string]interface{}{ - "metadata": map[string]interface{}{ - "team": "engineering", - "owner": "alice", - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "id": tenantID, - "metadata": map[string]interface{}{ - "team": "engineering", - "owner": "alice", - // Note: environment and region are gone (full replacement) - }, - }, - }, - }, - }, - { - Name: "GET /tenants/:tenantID verifies metadata was replaced", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "id": tenantID, - "metadata": map[string]interface{}{ - "team": "engineering", - "owner": "alice", - }, - }, - }, - }, - }, - { - Name: "PUT /tenants/:tenantID without metadata clears it", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - Body: map[string]interface{}{}, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "GET /tenants/:tenantID verifies metadata is nil", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "id": tenantID, - "destinations_count": 0, - "topics": []string{}, - // metadata field should not be present (omitempty) - }, - }, - }, - }, - { - Name: "PUT /tenants/:tenantID - Create new tenant with metadata", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + idgen.String(), - Body: map[string]interface{}{ - "metadata": map[string]interface{}{ - "stage": "development", - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - Body: map[string]interface{}{ - "metadata": map[string]interface{}{ - "stage": "development", - }, - }, - }, - }, - }, - { - Name: "PUT /tenants/:tenantID with metadata value auto-converted (number to string)", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + idgen.String(), - Body: map[string]interface{}{ - "metadata": map[string]interface{}{ - "count": 42, - "enabled": true, - "ratio": 3.14, - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - Body: map[string]interface{}{ - "metadata": map[string]interface{}{ - "count": "42", - "enabled": "true", - "ratio": "3.14", - }, - }, - }, - }, - }, - { - Name: "PUT /tenants/:tenantID with empty body (no metadata)", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + idgen.String(), - Body: map[string]interface{}{}, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - } - suite.RunAPITests(suite.T(), tests) -} - -func (suite *basicSuite) TestTenantAPIInvalidJSON() { - t := suite.T() - tenantID := idgen.String() - baseURL := fmt.Sprintf("http://localhost:%d/api/v1", suite.config.APIPort) - - // Create tenant with malformed JSON (send raw bytes) - jsonBody := []byte(`{"metadata": invalid json}`) - req, err := http.NewRequest(httpclient.MethodPUT, baseURL+"/tenants/"+tenantID, bytes.NewReader(jsonBody)) - require.NoError(t, err) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+suite.config.APIKey) - - httpClient := &http.Client{} - resp, err := httpClient.Do(req) - require.NoError(t, err) - defer resp.Body.Close() - - require.Equal(t, http.StatusBadRequest, resp.StatusCode, "Malformed JSON should return 400") -} - -func (suite *basicSuite) TestListTenantsAPI() { - t := suite.T() - - if !suite.hasRediSearch { - // Skip full test on backends without verified RediSearch support - // Note: Some backends (like Dragonfly) may pass the FT._LIST probe - // but not fully support FT.SEARCH, so we just skip the test - t.Skip("skipping ListTenant test - RediSearch not verified for this backend") - } - - // With RediSearch, test full list functionality - // Create some tenants first, with 1 second apart to ensure distinct timestamps - // (Dragonfly's FT.SEARCH SORTBY + LIMIT has issues with duplicate sort keys) - tenantIDs := make([]string, 3) - for i := 0; i < 3; i++ { - if i > 0 { - time.Sleep(time.Second) - } - tenantIDs[i] = idgen.String() - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantIDs[i], - })) - require.NoError(t, err) - require.Equal(t, http.StatusCreated, resp.StatusCode) - } - - // Test list without parameters - t.Run("list all tenants", func(t *testing.T) { - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants", - })) - require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode) - - body, ok := resp.Body.(map[string]interface{}) - require.True(t, ok, "response should be a map") - models, ok := body["models"].([]interface{}) - require.True(t, ok, "models should be an array") - assert.GreaterOrEqual(t, len(models), 3, "should have at least 3 tenants") - }) - - // Test list with limit - t.Run("list with limit", func(t *testing.T) { - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants?limit=2", - })) - require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode) - - body, ok := resp.Body.(map[string]interface{}) - require.True(t, ok, "response should be a map") - models, ok := body["models"].([]interface{}) - require.True(t, ok, "models should be an array") - assert.Equal(t, 2, len(models), "should have exactly 2 tenants") - }) - - // Test invalid limit - t.Run("invalid limit returns 400", func(t *testing.T) { - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants?limit=notanumber", - })) - require.NoError(t, err) - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - }) - - // Test forward pagination - t.Run("forward pagination with next cursor", func(t *testing.T) { - // Get first page - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants?limit=2", - })) - require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode) - - body, ok := resp.Body.(map[string]interface{}) - require.True(t, ok, "response should be a map") - models, ok := body["models"].([]interface{}) - require.True(t, ok, "models should be an array") - assert.Equal(t, 2, len(models), "page 1 should have 2 tenants") - - pagination, _ := body["pagination"].(map[string]interface{}) - next, _ := pagination["next"].(string) - require.NotEmpty(t, next, "should have next cursor") - - // Get second page using next cursor - resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants?limit=2&next=" + next, - })) - require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode) - - body, ok = resp.Body.(map[string]interface{}) - require.True(t, ok, "response should be a map") - models, ok = body["models"].([]interface{}) - require.True(t, ok, "models should be an array") - assert.GreaterOrEqual(t, len(models), 1, "page 2 should have at least 1 tenant") - - pagination, _ = body["pagination"].(map[string]interface{}) - prev, _ := pagination["prev"].(string) - assert.NotEmpty(t, prev, "page 2 should have prev cursor") - }) - - // Test prev cursor returns newer items (keyset pagination) - t.Run("backward pagination with prev cursor", func(t *testing.T) { - // Get first page - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants?limit=2", - })) - require.NoError(t, err) - body, ok := resp.Body.(map[string]interface{}) - require.True(t, ok) - - pagination, _ := body["pagination"].(map[string]interface{}) - next, _ := pagination["next"].(string) - require.NotEmpty(t, next, "should have next cursor") - - // Go to page 2 - resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants?limit=2&next=" + next, - })) - require.NoError(t, err) - body, ok = resp.Body.(map[string]interface{}) - require.True(t, ok) - - pagination, _ = body["pagination"].(map[string]interface{}) - prev, _ := pagination["prev"].(string) - require.NotEmpty(t, prev, "page 2 should have prev cursor") - - // Using prev cursor returns items with newer timestamps (keyset pagination) - // This is NOT the same as "going back to page 1" in offset pagination - resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants?limit=2&prev=" + prev, - })) - require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode) - - body, ok = resp.Body.(map[string]interface{}) - require.True(t, ok, "response should be a map") - models, ok := body["models"].([]interface{}) - require.True(t, ok, "models should be an array") - assert.NotEmpty(t, models, "prev cursor should return items") - }) - - // Cleanup - for _, id := range tenantIDs { - _, _ = suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodDELETE, - Path: "/" + id, - })) - } -} - -func (suite *basicSuite) TestDestinationsAPI() { - tenantID := idgen.String() - sampleDestinationID := idgen.Destination() - destinationWithMetadataID := idgen.Destination() - destinationWithFilterID := idgen.Destination() - tests := []APITest{ - { - Name: "PUT /tenants/:tenantID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destinations", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations", - }), - Expected: APITestExpectation{ - Validate: makeDestinationListValidator(0), - }, - }, - { - Name: "POST /tenants/:tenantID/destinations", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations with no body JSON", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusBadRequest, - Body: map[string]interface{}{ - "message": "invalid JSON", - }, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations with empty body JSON", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{}, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusUnprocessableEntity, - Body: map[string]interface{}{ - "message": "validation error", - "data": []interface{}{ - "type is required", - "topics is required", - }, - }, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations with invalid topics", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "type": "webhook", - "topics": "invalid", - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusUnprocessableEntity, - Body: map[string]interface{}{ - "message": "validation failed: invalid topics format", - }, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations with invalid topics", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "type": "webhook", - "topics": []string{"invalid"}, - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusUnprocessableEntity, - Body: map[string]interface{}{ - "message": "validation failed: invalid topics", - }, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations with invalid config", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "type": "webhook", - "topics": []string{"user.created"}, - "config": map[string]interface{}{}, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusUnprocessableEntity, - Body: map[string]interface{}{ - "message": "validation error", - "data": []interface{}{ - "config.url is required", - }, - }, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations with user-provided ID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "id": sampleDestinationID, - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations with delivery_metadata and metadata", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "id": destinationWithMetadataID, - "type": "webhook", - "topics": []string{"user.created"}, - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - "delivery_metadata": map[string]interface{}{ - "X-App-ID": "test-app", - "X-Version": "1.0", - }, - "metadata": map[string]interface{}{ - "environment": "test", - "team": "platform", - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destinations/:destinationID with delivery_metadata and metadata", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + destinationWithMetadataID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "id": destinationWithMetadataID, - "type": "webhook", - "topics": []string{"user.created"}, - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - "credentials": map[string]interface{}{}, - "delivery_metadata": map[string]interface{}{ - "X-App-ID": "test-app", - "X-Version": "1.0", - }, - "metadata": map[string]interface{}{ - "environment": "test", - "team": "platform", - }, - }, - }, - }, - }, - { - Name: "PATCH /tenants/:tenantID/destinations/:destinationID update delivery_metadata", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + destinationWithMetadataID, - Body: map[string]interface{}{ - "delivery_metadata": map[string]interface{}{ - "X-Version": "2.0", // Overwrite existing value (was "1.0") - "X-Region": "us-east-1", // Add new key - }, - // Note: X-App-ID not included, should be preserved from original - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "id": destinationWithMetadataID, - "type": "webhook", - "topics": []string{"user.created"}, - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - "credentials": map[string]interface{}{}, - "delivery_metadata": map[string]interface{}{ - "X-App-ID": "test-app", // PRESERVED: Not in PATCH request - "X-Version": "2.0", // OVERWRITTEN: Updated from "1.0" - "X-Region": "us-east-1", // NEW: Added by PATCH request - }, - "metadata": map[string]interface{}{ - "environment": "test", - "team": "platform", - }, - }, - }, - }, - }, - { - Name: "PATCH /tenants/:tenantID/destinations/:destinationID update metadata", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + destinationWithMetadataID, - Body: map[string]interface{}{ - "metadata": map[string]interface{}{ - "team": "engineering", // Overwrite existing value (was "platform") - "region": "us", // Add new key - }, - // Note: environment not included, should be preserved from original - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "id": destinationWithMetadataID, - "type": "webhook", - "topics": []string{"user.created"}, - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - "credentials": map[string]interface{}{}, - "delivery_metadata": map[string]interface{}{ - "X-App-ID": "test-app", - "X-Version": "2.0", - "X-Region": "us-east-1", - }, - "metadata": map[string]interface{}{ - "environment": "test", // PRESERVED: Not in PATCH request - "team": "engineering", // OVERWRITTEN: Updated from "platform" - "region": "us", // NEW: Added by PATCH request - }, - }, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destinations/:destinationID verify merged fields", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + destinationWithMetadataID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "id": destinationWithMetadataID, - "type": "webhook", - "topics": []string{"user.created"}, - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - "credentials": map[string]interface{}{}, - // Verify delivery_metadata merge behavior persists: - // - Original: {"X-App-ID": "test-app", "X-Version": "1.0"} - // - After PATCH 1: {"X-Version": "2.0", "X-Region": "us-east-1"} - // - Result: Preserved X-App-ID, overwrote X-Version, added X-Region - "delivery_metadata": map[string]interface{}{ - "X-App-ID": "test-app", - "X-Version": "2.0", - "X-Region": "us-east-1", - }, - // Verify metadata merge behavior persists: - // - Original: {"environment": "test", "team": "platform"} - // - After PATCH 2: {"team": "engineering", "region": "us"} - // - Result: Preserved environment, overwrote team, added region - "metadata": map[string]interface{}{ - "environment": "test", - "team": "engineering", - "region": "us", - }, - }, - }, - }, - }, - // Filter tests: create, update, and unset - { - Name: "POST /tenants/:tenantID/destinations with filter", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "id": destinationWithFilterID, - "type": "webhook", - "topics": []string{"user.created"}, - "filter": map[string]interface{}{ - "data": map[string]interface{}{ - "amount": map[string]interface{}{ - "$gte": 100, - }, - }, - }, - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - Body: map[string]interface{}{ - "id": destinationWithFilterID, - "filter": map[string]interface{}{ - "data": map[string]interface{}{ - "amount": map[string]interface{}{ - "$gte": float64(100), - }, - }, - }, - }, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destinations/:destinationID verify filter", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + destinationWithFilterID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "id": destinationWithFilterID, - "filter": map[string]interface{}{ - "data": map[string]interface{}{ - "amount": map[string]interface{}{ - "$gte": float64(100), - }, - }, - }, - }, - }, - }, - }, - { - Name: "PATCH /tenants/:tenantID/destinations/:destinationID update filter", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + destinationWithFilterID, - Body: map[string]interface{}{ - "filter": map[string]interface{}{ - "data": map[string]interface{}{ - "status": "active", - }, - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "id": destinationWithFilterID, - "filter": map[string]interface{}{ - "data": map[string]interface{}{ - "status": "active", - }, - }, - }, - }, - }, - }, - { - Name: "PATCH /tenants/:tenantID/destinations/:destinationID unset filter with empty object", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + destinationWithFilterID, - Body: map[string]interface{}{ - "filter": map[string]interface{}{}, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destinations/:destinationID verify filter unset", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + destinationWithFilterID, - }), - Expected: APITestExpectation{ - // Use JSON schema validation to verify filter is NOT present - Validate: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "statusCode": map[string]interface{}{ - "const": 200, - }, - "body": map[string]interface{}{ - "type": "object", - "required": []interface{}{"id", "type", "topics"}, - "not": map[string]interface{}{ - "required": []interface{}{"filter"}, - }, - }, - }, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations with duplicate ID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "id": sampleDestinationID, - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusBadRequest, - Body: map[string]interface{}{ - "message": "destination already exists", - }, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destinations", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations", - }), - Expected: APITestExpectation{ - Validate: makeDestinationListValidator(4), // 3 original + 1 with filter - }, - }, - { - Name: "GET /tenants/:tenantID/destinations/:destinationID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + sampleDestinationID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "id": sampleDestinationID, - "type": "webhook", - "topics": []string{"*"}, - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - "credentials": map[string]interface{}{}, - }, - }, - }, - }, - { - Name: "PATCH /tenants/:tenantID/destinations/:destinationID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + sampleDestinationID, - Body: map[string]interface{}{ - "topics": []string{"user.created"}, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "id": sampleDestinationID, - "type": "webhook", - "topics": []string{"user.created"}, - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - "credentials": map[string]interface{}{}, - }, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destinations/:destinationID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + sampleDestinationID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "id": sampleDestinationID, - "type": "webhook", - "topics": []string{"user.created"}, - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - "credentials": map[string]interface{}{}, - }, - }, - }, - }, - { - Name: "PATCH /tenants/:tenantID/destinations/:destinationID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + sampleDestinationID, - Body: map[string]interface{}{ - "topics": []string{""}, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusUnprocessableEntity, - Body: map[string]interface{}{ - "message": "validation failed: invalid topics", - }, - }, - }, - }, - { - Name: "PATCH /tenants/:tenantID/destinations/:destinationID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + sampleDestinationID, - Body: map[string]interface{}{ - "config": map[string]interface{}{ - "url": "", - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusUnprocessableEntity, - Body: map[string]interface{}{ - "message": "validation error", - "data": []interface{}{ - "config.url is required", - }, - }, - }, - }, - }, - { - Name: "DELETE /tenants/:tenantID/destinations/:destinationID with invalid destination ID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodDELETE, - Path: "/tenants/" + tenantID + "/destinations/" + idgen.Destination(), - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusNotFound, - }, - }, - }, - { - Name: "DELETE /tenants/:tenantID/destinations/:destinationID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodDELETE, - Path: "/tenants/" + tenantID + "/destinations/" + sampleDestinationID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destinations/:destinationID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + sampleDestinationID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusNotFound, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destinations", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations", - }), - Expected: APITestExpectation{ - Validate: makeDestinationListValidator(3), // 4 - 1 deleted = 3 - }, - }, - { - Name: "POST /tenants/:tenantID/destinations with metadata auto-conversion", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - "metadata": map[string]interface{}{ - "priority": 10, - "enabled": true, - "version": 1.5, - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - Body: map[string]interface{}{ - "metadata": map[string]interface{}{ - "priority": "10", - "enabled": "true", - "version": "1.5", - }, - }, - }, - }, - }, - } - suite.RunAPITests(suite.T(), tests) -} - -func (suite *basicSuite) TestEntityUpdatedAt() { - t := suite.T() - tenantID := idgen.String() - destinationID := idgen.Destination() - - // Create tenant and verify timestamps in PUT response directly - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - })) - require.NoError(t, err) - require.Equal(t, http.StatusCreated, resp.StatusCode) - - body := resp.Body.(map[string]interface{}) - require.NotNil(t, body["created_at"], "created_at should be present") - require.NotNil(t, body["updated_at"], "updated_at should be present") - - tenantCreatedAt := body["created_at"].(string) - tenantUpdatedAt := body["updated_at"].(string) - // On creation, created_at and updated_at should be very close (within 1 second) - createdTime, err := time.Parse(time.RFC3339Nano, tenantCreatedAt) - require.NoError(t, err) - updatedTime, err := time.Parse(time.RFC3339Nano, tenantUpdatedAt) - require.NoError(t, err) - require.WithinDuration(t, createdTime, updatedTime, time.Second, "created_at and updated_at should be close on creation") - - // Wait to ensure different timestamp (Unix timestamps have second precision) - time.Sleep(1100 * time.Millisecond) - - // Update tenant - resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - Body: map[string]interface{}{ - "metadata": map[string]interface{}{ - "env": "production", - }, - }, - })) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - // Get tenant again and verify updated_at changed but created_at didn't - resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID, - })) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - body = resp.Body.(map[string]interface{}) - newTenantCreatedAt := body["created_at"].(string) - newTenantUpdatedAt := body["updated_at"].(string) - - // Parse timestamps to compare actual times (format may differ between responses) - newCreatedTime, err := time.Parse(time.RFC3339Nano, newTenantCreatedAt) - require.NoError(t, err) - newUpdatedTime, err := time.Parse(time.RFC3339Nano, newTenantUpdatedAt) - require.NoError(t, err) - - require.Equal(t, createdTime.Unix(), newCreatedTime.Unix(), "created_at should not change") - require.NotEqual(t, updatedTime.Unix(), newUpdatedTime.Unix(), "updated_at should change") - require.True(t, newUpdatedTime.After(updatedTime), "updated_at should be newer") - - // Create destination and verify timestamps in POST response directly - resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "topics": []string{"*"}, - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - }, - })) - require.NoError(t, err) - require.Equal(t, http.StatusCreated, resp.StatusCode) - - body = resp.Body.(map[string]interface{}) - require.NotNil(t, body["created_at"], "created_at should be present") - require.NotNil(t, body["updated_at"], "updated_at should be present") - - destCreatedAt := body["created_at"].(string) - destUpdatedAt := body["updated_at"].(string) - // On creation, created_at and updated_at should be very close (within 1 second) - createdTime, err = time.Parse(time.RFC3339Nano, destCreatedAt) - require.NoError(t, err) - updatedTime, err = time.Parse(time.RFC3339Nano, destUpdatedAt) - require.NoError(t, err) - require.WithinDuration(t, createdTime, updatedTime, time.Second, "created_at and updated_at should be close on creation") - - // Wait to ensure different timestamp (Unix timestamps have second precision) - time.Sleep(1100 * time.Millisecond) - - // Update destination - resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - Body: map[string]interface{}{ - "topics": []string{"user.created"}, - }, - })) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - // Get destination again and verify updated_at changed but created_at didn't - resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - })) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - body = resp.Body.(map[string]interface{}) - newDestCreatedAt := body["created_at"].(string) - newDestUpdatedAt := body["updated_at"].(string) - - // Parse timestamps to compare actual times (format may differ between responses) - newDestCreatedTime, err := time.Parse(time.RFC3339Nano, newDestCreatedAt) - require.NoError(t, err) - newDestUpdatedTime, err := time.Parse(time.RFC3339Nano, newDestUpdatedAt) - require.NoError(t, err) - - require.Equal(t, createdTime.Unix(), newDestCreatedTime.Unix(), "created_at should not change") - require.NotEqual(t, updatedTime.Unix(), newDestUpdatedTime.Unix(), "updated_at should change") - require.True(t, newDestUpdatedTime.After(updatedTime), "updated_at should be newer") -} - -func (suite *basicSuite) TestDestinationsListAPI() { - tenantID := idgen.String() - tests := []APITest{ - { - Name: "PUT /tenants/:tenantID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations type=webhook topics=*", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations type=webhook topics=user.created", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "type": "webhook", - "topics": []string{"user.created"}, - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations type=webhook topics=user.created user.updated", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "type": "webhook", - "topics": []string{"user.created", "user.updated"}, - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destinations", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations", - }), - Expected: APITestExpectation{ - Validate: makeDestinationListValidator(3), - }, - }, - { - Name: "GET /tenants/:tenantID/destinations?type=webhook", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations?type=webhook", - }), - Expected: APITestExpectation{ - Validate: makeDestinationListValidator(3), - }, - }, - { - Name: "GET /tenants/:tenantID/destinations?type=rabbitmq", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations?type=rabbitmq", - }), - Expected: APITestExpectation{ - Validate: makeDestinationListValidator(0), - }, - }, - { - Name: "GET /tenants/:tenantID/destinations?topics=*", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations?topics=*", - }), - Expected: APITestExpectation{ - Validate: makeDestinationListValidator(1), - }, - }, - { - Name: "GET /tenants/:tenantID/destinations?topics=user.created", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations?topics=user.created", - }), - Expected: APITestExpectation{ - Validate: makeDestinationListValidator(3), - }, - }, - { - Name: "GET /tenants/:tenantID/destinations?topics=user.updated", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations?topics=user.updated", - }), - Expected: APITestExpectation{ - Validate: makeDestinationListValidator(2), - }, - }, - { - Name: "GET /tenants/:tenantID/destinations?topics=user.created&topics=user.updated", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations?topics=user.created&topics=user.updated", - }), - Expected: APITestExpectation{ - Validate: makeDestinationListValidator(2), - }, - }, - { - Name: "GET /tenants/:tenantID/destinations?type=webhook&topics=user.created&topics=user.updated", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations?type=webhook&topics=user.created&topics=user.updated", - }), - Expected: APITestExpectation{ - Validate: makeDestinationListValidator(2), - }, - }, - { - Name: "GET /tenants/:tenantID/destinations?type=rabbitmq&topics=user.created&topics=user.updated", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations?type=rabbitmq&topics=user.created&topics=user.updated", - }), - Expected: APITestExpectation{ - Validate: makeDestinationListValidator(0), - }, - }, - } - suite.RunAPITests(suite.T(), tests) -} - -func (suite *basicSuite) TestDestinationEnableDisableAPI() { - tenantID := idgen.String() - sampleDestinationID := idgen.Destination() - tests := []APITest{ - { - Name: "PUT /tenants/:tenantID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "id": sampleDestinationID, - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destinations/:destinationID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + sampleDestinationID, - }), - Expected: APITestExpectation{ - Validate: makeDestinationDisabledValidator(sampleDestinationID, false), - }, - }, - { - Name: "PUT /tenants/:tenantID/destinations/:destinationID/disable", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID + "/destinations/" + sampleDestinationID + "/disable", - }), - Expected: APITestExpectation{ - Validate: makeDestinationDisabledValidator(sampleDestinationID, true), - }, - }, - { - Name: "GET /tenants/:tenantID/destinations/:destinationID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + sampleDestinationID, - }), - Expected: APITestExpectation{ - Validate: makeDestinationDisabledValidator(sampleDestinationID, true), - }, - }, - { - Name: "PUT /tenants/:tenantID/destinations/:destinationID/enable", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID + "/destinations/" + sampleDestinationID + "/enable", - }), - Expected: APITestExpectation{ - Validate: makeDestinationDisabledValidator(sampleDestinationID, false), - }, - }, - { - Name: "GET /tenants/:tenantID/destinations/:destinationID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + sampleDestinationID, - }), - Expected: APITestExpectation{ - Validate: makeDestinationDisabledValidator(sampleDestinationID, false), - }, - }, - { - Name: "PUT /tenants/:tenantID/destinations/:destinationID/enable duplicate", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID + "/destinations/" + sampleDestinationID + "/enable", - }), - Expected: APITestExpectation{ - Validate: makeDestinationDisabledValidator(sampleDestinationID, false), - }, - }, - { - Name: "GET /tenants/:tenantID/destinations/:destinationID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + sampleDestinationID, - }), - Expected: APITestExpectation{ - Validate: makeDestinationDisabledValidator(sampleDestinationID, false), - }, - }, - { - Name: "PUT /tenants/:tenantID/destinations/:destinationID/disable", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID + "/destinations/" + sampleDestinationID + "/disable", - }), - Expected: APITestExpectation{ - Validate: makeDestinationDisabledValidator(sampleDestinationID, true), - }, - }, - { - Name: "PUT /tenants/:tenantID/destinations/:destinationID/disable duplicate", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID + "/destinations/" + sampleDestinationID + "/disable", - }), - Expected: APITestExpectation{ - Validate: makeDestinationDisabledValidator(sampleDestinationID, true), - }, - }, - { - Name: "GET /tenants/:tenantID/destinations/:destinationID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + sampleDestinationID, - }), - Expected: APITestExpectation{ - Validate: makeDestinationDisabledValidator(sampleDestinationID, true), - }, - }, - } - suite.RunAPITests(suite.T(), tests) -} - -func (suite *basicSuite) TestTopicsAPI() { - tenantID := idgen.String() - tests := []APITest{ - { - Name: "PUT /tenants/:tenantID - Create tenant", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/topics", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/topics", - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: suite.config.Topics, - }, - }, - }, - } - suite.RunAPITests(suite.T(), tests) -} - -func (suite *basicSuite) TestDestinationTypesAPI() { - providerFieldSchema := map[string]interface{}{ - "type": "object", - "required": []interface{}{"key", "type", "label", "description", "required"}, - "properties": map[string]interface{}{ - "key": map[string]interface{}{"type": "string"}, - "type": map[string]interface{}{"type": "string"}, - "label": map[string]interface{}{"type": "string"}, - "description": map[string]interface{}{"type": "string"}, - "required": map[string]interface{}{"type": "boolean"}, - }, - } - - providerSchema := map[string]interface{}{ - "type": "object", - "required": []interface{}{"type", "label", "description", "icon", "config_fields", "credential_fields"}, - "properties": map[string]interface{}{ - "type": map[string]interface{}{"type": "string"}, - "label": map[string]interface{}{"type": "string"}, - "description": map[string]interface{}{"type": "string"}, - "icon": map[string]interface{}{"type": "string"}, - "instructions": map[string]interface{}{"type": "string"}, - "config_fields": map[string]interface{}{ - "type": "array", - "items": providerFieldSchema, - }, - "credential_fields": map[string]interface{}{ - "type": "array", - "items": providerFieldSchema, - }, - "validation": map[string]interface{}{ - "type": "object", - }, - }, - } - - tenantID := idgen.String() - tests := []APITest{ - { - Name: "PUT /tenants/:tenantID - Create tenant", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destination-types", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destination-types", - }), - Expected: APITestExpectation{ - Validate: map[string]any{ - "type": "object", - "properties": map[string]any{ - "statusCode": map[string]any{"const": 200}, - "body": map[string]interface{}{ - "type": "array", - "items": providerSchema, - "minItems": 8, - "maxItems": 8, - "uniqueItems": true, - }, - }, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destination-types/webhook", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destination-types/webhook", - }), - Expected: APITestExpectation{ - Validate: map[string]any{ - "type": "object", - "properties": map[string]any{ - "statusCode": map[string]any{"const": 200}, - "body": providerSchema, - }, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destination-types/invalid", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destination-types/invalid", - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusNotFound, - }, - }, - }, - } - suite.RunAPITests(suite.T(), tests) -} - -func (suite *basicSuite) TestTenantScopedAPI() { - // Step 1: Create tenant and get JWT token - tenantID := idgen.String() - destinationID := idgen.Destination() - - // Create tenant first using admin auth - createTenantTests := []APITest{ - { - Name: "PUT /tenants/:tenantID to create tenant", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - } - suite.RunAPITests(suite.T(), createTenantTests) - - // Step 2: Get JWT token - need to do this manually since we need to extract the token - tokenResp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/token", - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, tokenResp.StatusCode) - - bodyMap := tokenResp.Body.(map[string]interface{}) - token := bodyMap["token"].(string) - suite.Require().NotEmpty(token) - - // Verify tenant_id is returned and matches the requested tenant - returnedTenantID := bodyMap["tenant_id"].(string) - suite.Require().Equal(tenantID, returnedTenantID, "tenant_id in token response should match the requested tenant") - - // Step 3: Test various endpoints with JWT auth - jwtTests := []APITest{ - // Test tenant-specific routes with tenantID param - { - Name: "GET /tenants/:tenantID with JWT should work", - Request: suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID, - }, token), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destinations with JWT should work", - Request: suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations", - }, token), - Expected: APITestExpectation{ - Validate: makeDestinationListValidator(0), - }, - }, - { - Name: "POST /tenants/:tenantID/destinations with JWT should work", - Request: suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": "http://host.docker.internal:4444", - }, - }, - }, token), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - - // Test tenant routes with JWT auth - { - Name: "GET /tenants/:tenantID/destination-types with JWT should work", - Request: suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destination-types", - }, token), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/topics with JWT should work", - Request: suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/topics", - }, token), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - - // Test wrong tenantID - { - Name: "GET /tenants/wrong-tenant-id with JWT should fail", - Request: suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + idgen.String(), - }, token), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusUnauthorized, - }, - }, - }, - - // Clean up - delete tenant - { - Name: "DELETE /tenants/:tenantID with JWT should work", - Request: suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodDELETE, - Path: "/tenants/" + tenantID, - }, token), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - } - - suite.RunAPITests(suite.T(), jwtTests) -} - -func (suite *basicSuite) TestAdminOnlyRoutesRejectJWT() { - // Step 1: Create tenant and get JWT token - tenantID := idgen.String() - - // Create tenant first using admin auth - createTenantTests := []APITest{ - { - Name: "PUT /tenants/:tenantID to create tenant", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - } - suite.RunAPITests(suite.T(), createTenantTests) - - // Step 2: Get JWT token - tokenResp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/token", - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, tokenResp.StatusCode) - - bodyMap := tokenResp.Body.(map[string]interface{}) - token := bodyMap["token"].(string) - suite.Require().NotEmpty(token) - - // Step 3: Test admin-only routes with JWT auth should be rejected - adminOnlyTests := []APITest{ - // PUT /tenants/:id is admin-only (create/update tenant) - { - Name: "PUT /tenants/:tenantID with JWT should return 401", - Request: suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - }, token), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusUnauthorized, - }, - }, - }, - // GET /tenants/:id/token is admin-only (retrieve token) - { - Name: "GET /tenants/:tenantID/token with JWT should return 401", - Request: suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/token", - }, token), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusUnauthorized, - }, - }, - }, - // GET /tenants/:id/portal is admin-only (retrieve portal redirect) - { - Name: "GET /tenants/:tenantID/portal with JWT should return 401", - Request: suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/portal", - }, token), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusUnauthorized, - }, - }, - }, - // GET /tenants (list) is admin-only - { - Name: "GET /tenants with JWT should return 401", - Request: suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants", - }, token), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusUnauthorized, - }, - }, - }, - // POST /publish is admin-only - { - Name: "POST /publish with JWT should return 401", - Request: suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/publish", - Body: map[string]interface{}{ - "tenant_id": tenantID, - "topic": "user.created", - "data": map[string]interface{}{"test": "data"}, - }, - }, token), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusUnauthorized, - }, - }, - }, - } - - suite.RunAPITests(suite.T(), adminOnlyTests) - - // Cleanup: delete tenant using admin auth - cleanupTests := []APITest{ - { - Name: "DELETE /tenants/:tenantID cleanup", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodDELETE, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - } - suite.RunAPITests(suite.T(), cleanupTests) -} - -func makeDestinationListValidator(length int) map[string]any { - return map[string]any{ - "type": "object", - "properties": map[string]any{ - "statusCode": map[string]any{ - "const": 200, - }, - "body": map[string]any{ - "type": "array", - "minItems": length, - "maxItems": length, - "items": map[string]any{ - "type": "object", - "properties": map[string]any{ - "id": map[string]any{ - "type": "string", - }, - "type": map[string]any{ - "type": "string", - }, - "config": map[string]any{ - "type": "object", - }, - "credentials": map[string]any{ - "type": "object", - }, - }, - "required": []any{"id", "type", "config", "credentials"}, - }, - }, - }, - } -} - func makeDestinationDisabledValidator(id string, disabled bool) map[string]any { var disabledValidator map[string]any if disabled { diff --git a/cmd/e2e/destwebhook_test.go b/cmd/e2e/destwebhook_test.go index 66c0e22f..18b5833a 100644 --- a/cmd/e2e/destwebhook_test.go +++ b/cmd/e2e/destwebhook_test.go @@ -10,11 +10,6 @@ import ( "github.com/stretchr/testify/require" ) -// TestingT is an interface wrapper around *testing.T -type TestingT interface { - Errorf(format string, args ...interface{}) -} - func (suite *basicSuite) TestDestwebhookPublish() { tenantID := idgen.String() sampleDestinationID := idgen.Destination() @@ -531,7 +526,7 @@ func (suite *basicSuite) TestDestwebhookTenantSecretManagement() { Body: map[string]interface{}{ "message": "validation error", "data": []interface{}{ - "credentials.secret failed forbidden validation", + "credentials.secret is forbidden", }, }, }, @@ -594,7 +589,7 @@ func (suite *basicSuite) TestDestwebhookTenantSecretManagement() { Body: map[string]interface{}{ "message": "validation error", "data": []interface{}{ - "credentials.secret failed forbidden validation", + "credentials.secret is forbidden", }, }, }, @@ -617,7 +612,7 @@ func (suite *basicSuite) TestDestwebhookTenantSecretManagement() { Body: map[string]interface{}{ "message": "validation error", "data": []interface{}{ - "credentials.previous_secret failed forbidden validation", + "credentials.previous_secret is forbidden", }, }, }, @@ -640,7 +635,7 @@ func (suite *basicSuite) TestDestwebhookTenantSecretManagement() { Body: map[string]interface{}{ "message": "validation error", "data": []interface{}{ - "credentials.previous_secret_invalid_at failed forbidden validation", + "credentials.previous_secret_invalid_at is forbidden", }, }, }, @@ -1468,11 +1463,11 @@ func (suite *basicSuite) TestDeliveryRetry() { suite.waitForMockServerEvents(t, destinationID, 2, 5*time.Second) // Wait for attempts to be logged, then verify attempt_number increments on automated retry - suite.waitForAttempts(t, "/tenants/"+tenantID+"/attempts", 2, 5*time.Second) + suite.waitForAttempts(t, "/attempts?tenant_id="+tenantID, 2, 5*time.Second) atmResponse, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/attempts?dir=asc", + Path: "/attempts?tenant_id=" + tenantID + "&dir=asc", })) require.NoError(t, err) require.Equal(t, http.StatusOK, atmResponse.StatusCode) diff --git a/cmd/e2e/log_test.go b/cmd/e2e/log_test.go index 5cc04ddd..f31beb9e 100644 --- a/cmd/e2e/log_test.go +++ b/cmd/e2e/log_test.go @@ -112,7 +112,7 @@ func (suite *basicSuite) TestLogAPI() { } // Wait for all attempts (30s timeout for slow CI environments) - suite.waitForAttempts(suite.T(), "/tenants/"+tenantID+"/attempts", 10, 10*time.Second) + suite.waitForAttempts(suite.T(), "/attempts?tenant_id="+tenantID, 10, 10*time.Second) // ========================================================================= // Attempts Tests @@ -121,7 +121,7 @@ func (suite *basicSuite) TestLogAPI() { suite.Run("list all", func() { resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/attempts", + Path: "/attempts?tenant_id=" + tenantID, })) suite.Require().NoError(err) suite.Require().Equal(http.StatusOK, resp.StatusCode) @@ -143,7 +143,7 @@ func (suite *basicSuite) TestLogAPI() { suite.Run("filter by destination_id", func() { resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/attempts?destination_id=" + destinationID, + Path: "/attempts?tenant_id=" + tenantID + "&destination_id=" + destinationID, })) suite.Require().NoError(err) suite.Require().Equal(http.StatusOK, resp.StatusCode) @@ -156,7 +156,7 @@ func (suite *basicSuite) TestLogAPI() { suite.Run("filter by event_id", func() { resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/attempts?event_id=" + eventIDs[0], + Path: "/attempts?tenant_id=" + tenantID + "&event_id=" + eventIDs[0], })) suite.Require().NoError(err) suite.Require().Equal(http.StatusOK, resp.StatusCode) @@ -169,7 +169,7 @@ func (suite *basicSuite) TestLogAPI() { suite.Run("include=event returns event object without data", func() { resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/attempts?include=event&limit=1", + Path: "/attempts?tenant_id=" + tenantID + "&include=event&limit=1", })) suite.Require().NoError(err) suite.Require().Equal(http.StatusOK, resp.StatusCode) @@ -189,7 +189,7 @@ func (suite *basicSuite) TestLogAPI() { suite.Run("include=event.data returns event object with data", func() { resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/attempts?include=event.data&limit=1", + Path: "/attempts?tenant_id=" + tenantID + "&include=event.data&limit=1", })) suite.Require().NoError(err) suite.Require().Equal(http.StatusOK, resp.StatusCode) @@ -207,7 +207,7 @@ func (suite *basicSuite) TestLogAPI() { suite.Run("include=response_data returns response data", func() { resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/attempts?include=response_data&limit=1", + Path: "/attempts?tenant_id=" + tenantID + "&include=response_data&limit=1", })) suite.Require().NoError(err) suite.Require().Equal(http.StatusOK, resp.StatusCode) @@ -228,7 +228,7 @@ func (suite *basicSuite) TestLogAPI() { suite.Run("list all", func() { resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/events", + Path: "/events?tenant_id=" + tenantID, })) suite.Require().NoError(err) suite.Require().Equal(http.StatusOK, resp.StatusCode) @@ -248,7 +248,7 @@ func (suite *basicSuite) TestLogAPI() { suite.Run("filter by topic", func() { resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/events?topic=user.created", + Path: "/events?tenant_id=" + tenantID + "&topic=user.created", })) suite.Require().NoError(err) suite.Require().Equal(http.StatusOK, resp.StatusCode) @@ -261,7 +261,7 @@ func (suite *basicSuite) TestLogAPI() { suite.Run("retrieve single event", func() { resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/events/" + eventIDs[0], + Path: "/events/" + eventIDs[0], })) suite.Require().NoError(err) suite.Require().Equal(http.StatusOK, resp.StatusCode) @@ -275,7 +275,7 @@ func (suite *basicSuite) TestLogAPI() { suite.Run("retrieve non-existent event returns 404", func() { resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/events/" + idgen.Event(), + Path: "/events/" + idgen.Event(), })) suite.Require().NoError(err) suite.Equal(http.StatusNotFound, resp.StatusCode) @@ -285,7 +285,7 @@ func (suite *basicSuite) TestLogAPI() { futureTime := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339) resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/events?time[gte]=" + futureTime, + Path: "/events?tenant_id=" + tenantID + "&time[gte]=" + futureTime, })) suite.Require().NoError(err) suite.Require().Equal(http.StatusOK, resp.StatusCode) @@ -303,7 +303,7 @@ func (suite *basicSuite) TestLogAPI() { suite.Run("events desc returns newest first", func() { resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/events?dir=desc", + Path: "/events?tenant_id=" + tenantID + "&dir=desc", })) suite.Require().NoError(err) suite.Require().Equal(http.StatusOK, resp.StatusCode) @@ -322,7 +322,7 @@ func (suite *basicSuite) TestLogAPI() { suite.Run("events asc returns oldest first", func() { resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/events?dir=asc", + Path: "/events?tenant_id=" + tenantID + "&dir=asc", })) suite.Require().NoError(err) suite.Require().Equal(http.StatusOK, resp.StatusCode) @@ -341,7 +341,7 @@ func (suite *basicSuite) TestLogAPI() { suite.Run("events invalid dir returns 422", func() { resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/events?dir=invalid", + Path: "/events?tenant_id=" + tenantID + "&dir=invalid", })) suite.Require().NoError(err) suite.Equal(http.StatusUnprocessableEntity, resp.StatusCode) @@ -359,7 +359,7 @@ func (suite *basicSuite) TestLogAPI() { pageCount := 0 for { - path := "/tenants/" + tenantID + "/events?limit=3&dir=asc" + path := "/events?tenant_id=" + tenantID + "&limit=3&dir=asc" if nextCursor != "" { path += "&next=" + nextCursor } @@ -401,7 +401,7 @@ func (suite *basicSuite) TestLogAPI() { // Get all events to establish a time window resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/events?dir=asc&limit=10", + Path: "/events?tenant_id=" + tenantID + "&dir=asc&limit=10", })) suite.Require().NoError(err) suite.Require().Equal(http.StatusOK, resp.StatusCode) @@ -424,7 +424,7 @@ func (suite *basicSuite) TestLogAPI() { pageCount := 0 for { - path := "/tenants/" + tenantID + "/events?dir=asc&limit=2" + path := "/events?tenant_id=" + tenantID + "&dir=asc&limit=2" path += "&time[gte]=" + timeGTE + "&time[lte]=" + timeLTE if nextCursor != "" { path += "&next=" + nextCursor @@ -513,10 +513,10 @@ func (suite *basicSuite) TestLogAPI() { // 6. Update mock server to SUCCEED (return 200) // // Test Cases: -// - POST /:tenantID/attempts/:attemptID/retry - Successful retry returns 202 Accepted -// - POST /:tenantID/attempts/:attemptID/retry (non-existent) - Returns 404 +// - POST /retry - Successful retry returns 202 Accepted +// - POST /retry (non-existent event) - Returns 404 // - Verify retry created new attempt - Event now has 2+ attempts -// - POST /:tenantID/attempts/:attemptID/retry (disabled destination) - Returns 400 +// - POST /retry (disabled destination) - Returns 400 func (suite *basicSuite) TestRetryAPI() { tenantID := idgen.String() destinationID := idgen.Destination() @@ -604,12 +604,12 @@ func (suite *basicSuite) TestRetryAPI() { suite.RunAPITests(suite.T(), setupTests) // Wait for attempt to complete (and fail) - suite.waitForAttempts(suite.T(), "/tenants/"+tenantID+"/attempts?event_id="+eventID, 1, 5*time.Second) + suite.waitForAttempts(suite.T(), "/attempts?tenant_id="+tenantID+"&event_id="+eventID, 1, 5*time.Second) // Get the attempt ID attemptsResp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/attempts?event_id=" + eventID, + Path: "/attempts?tenant_id=" + tenantID + "&event_id=" + eventID, })) suite.Require().NoError(err) suite.Require().Equal(http.StatusOK, attemptsResp.StatusCode) @@ -618,7 +618,6 @@ func (suite *basicSuite) TestRetryAPI() { models := body["models"].([]interface{}) suite.Require().NotEmpty(models, "should have at least one attempt") firstAttempt := models[0].(map[string]interface{}) - attemptID := firstAttempt["id"].(string) // Verify first attempt has attempt_number=0 suite.Equal(float64(0), firstAttempt["attempt_number"], "first attempt should have attempt_number=0") @@ -653,12 +652,16 @@ func (suite *basicSuite) TestRetryAPI() { // Test retry endpoint retryTests := []APITest{ - // POST /:tenantID/attempts/:attemptID/retry - successful retry + // POST /retry - successful retry { - Name: "POST /:tenantID/attempts/:attemptID/retry - retry attempt", + Name: "POST /retry - retry event", Request: suite.AuthRequest(httpclient.Request{ Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/attempts/" + attemptID + "/retry", + Path: "/retry", + Body: map[string]interface{}{ + "event_id": eventID, + "destination_id": destinationID, + }, }), Expected: APITestExpectation{ Match: &httpclient.Response{ @@ -669,12 +672,16 @@ func (suite *basicSuite) TestRetryAPI() { }, }, }, - // POST /:tenantID/attempts/:attemptID/retry - non-existent attempt + // POST /retry - non-existent event { - Name: "POST /:tenantID/attempts/:attemptID/retry - not found", + Name: "POST /retry - not found", Request: suite.AuthRequest(httpclient.Request{ Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/attempts/" + idgen.Attempt() + "/retry", + Path: "/retry", + Body: map[string]interface{}{ + "event_id": idgen.Event(), + "destination_id": destinationID, + }, }), Expected: APITestExpectation{ Match: &httpclient.Response{ @@ -686,12 +693,12 @@ func (suite *basicSuite) TestRetryAPI() { suite.RunAPITests(suite.T(), retryTests) // Wait for retry attempt to complete - suite.waitForAttempts(suite.T(), "/tenants/"+tenantID+"/attempts?event_id="+eventID, 2, 5*time.Second) + suite.waitForAttempts(suite.T(), "/attempts?tenant_id="+tenantID+"&event_id="+eventID, 2, 5*time.Second) // Verify retry created a new attempt with incremented attempt_number verifyResp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/attempts?event_id=" + eventID + "&dir=asc", + Path: "/attempts?tenant_id=" + tenantID + "&event_id=" + eventID + "&dir=asc", })) suite.Require().NoError(err) suite.Require().Equal(http.StatusOK, verifyResp.StatusCode) @@ -731,10 +738,14 @@ func (suite *basicSuite) TestRetryAPI() { }, }, { - Name: "POST /:tenantID/attempts/:attemptID/retry - disabled destination", + Name: "POST /retry - disabled destination", Request: suite.AuthRequest(httpclient.Request{ Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/attempts/" + attemptID + "/retry", + Path: "/retry", + Body: map[string]interface{}{ + "event_id": eventID, + "destination_id": destinationID, + }, }), Expected: APITestExpectation{ Match: &httpclient.Response{ @@ -778,359 +789,3 @@ func (suite *basicSuite) TestRetryAPI() { } suite.RunAPITests(suite.T(), cleanupTests) } - -// TestAdminLogEndpoints tests the admin-only /events and /attempts endpoints. -// -// These endpoints allow cross-tenant queries with optional tenant_id filter. -// -// Setup: -// 1. Create two tenants with destinations -// 2. Publish events to each tenant -// 3. Wait for attempts to complete -// -// Test Cases: -// - GET /events without auth returns 401 -// - GET /attempts without auth returns 401 -// - GET /events with JWT returns 401 (admin-only) -// - GET /attempts with JWT returns 401 (admin-only) -// - GET /events with admin key returns all events (cross-tenant) -// - GET /attempts with admin key returns all attempts (cross-tenant) -// - GET /events?tenant_id=X filters to single tenant -// - GET /attempts?tenant_id=X filters to single tenant -func (suite *basicSuite) TestAdminLogEndpoints() { - tenant1ID := idgen.String() - tenant2ID := idgen.String() - destination1ID := idgen.Destination() - destination2ID := idgen.Destination() - event1ID := idgen.Event() - event2ID := idgen.Event() - - // Setup: create two tenants with destinations - setupTests := []APITest{ - { - Name: "create tenant1", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenant1ID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{StatusCode: http.StatusCreated}, - }, - }, - { - Name: "create tenant2", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenant2ID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{StatusCode: http.StatusCreated}, - }, - }, - { - Name: "setup mock server for tenant1", - Request: httpclient.Request{ - Method: httpclient.MethodPUT, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations", - Body: map[string]interface{}{ - "id": destination1ID, - "type": "webhook", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destination1ID), - }, - }, - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{StatusCode: http.StatusOK}, - }, - }, - { - Name: "setup mock server for tenant2", - Request: httpclient.Request{ - Method: httpclient.MethodPUT, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations", - Body: map[string]interface{}{ - "id": destination2ID, - "type": "webhook", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destination2ID), - }, - }, - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{StatusCode: http.StatusOK}, - }, - }, - { - Name: "create destination for tenant1", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenant1ID + "/destinations", - Body: map[string]interface{}{ - "id": destination1ID, - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destination1ID), - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{StatusCode: http.StatusCreated}, - }, - }, - { - Name: "create destination for tenant2", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenant2ID + "/destinations", - Body: map[string]interface{}{ - "id": destination2ID, - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destination2ID), - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{StatusCode: http.StatusCreated}, - }, - }, - { - Name: "publish event to tenant1", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/publish", - Body: map[string]interface{}{ - "id": event1ID, - "tenant_id": tenant1ID, - "topic": "user.created", - "data": map[string]interface{}{"tenant": "1"}, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{StatusCode: http.StatusAccepted}, - }, - }, - { - Name: "publish event to tenant2", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/publish", - Body: map[string]interface{}{ - "id": event2ID, - "tenant_id": tenant2ID, - "topic": "user.created", - "data": map[string]interface{}{"tenant": "2"}, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{StatusCode: http.StatusAccepted}, - }, - }, - } - suite.RunAPITests(suite.T(), setupTests) - - // Wait for attempts for both tenants - suite.waitForAttempts(suite.T(), "/tenants/"+tenant1ID+"/attempts", 1, 5*time.Second) - suite.waitForAttempts(suite.T(), "/tenants/"+tenant2ID+"/attempts", 1, 5*time.Second) - - // Get JWT token for tenant1 to test that JWT auth is rejected on admin endpoints - tokenResp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenant1ID + "/token", - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, tokenResp.StatusCode) - bodyMap := tokenResp.Body.(map[string]interface{}) - jwtToken := bodyMap["token"].(string) - suite.Require().NotEmpty(jwtToken) - - // ========================================================================= - // Auth Tests: verify endpoints require admin API key - // ========================================================================= - suite.Run("auth", func() { - suite.Run("GET /events without auth returns 401", func() { - resp, err := suite.client.Do(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/events", - }) - suite.Require().NoError(err) - suite.Equal(http.StatusUnauthorized, resp.StatusCode) - }) - - suite.Run("GET /attempts without auth returns 401", func() { - resp, err := suite.client.Do(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/attempts", - }) - suite.Require().NoError(err) - suite.Equal(http.StatusUnauthorized, resp.StatusCode) - }) - - suite.Run("GET /events with JWT returns 401 (admin-only)", func() { - resp, err := suite.client.Do(suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/events", - }, jwtToken)) - suite.Require().NoError(err) - suite.Equal(http.StatusUnauthorized, resp.StatusCode) - }) - - suite.Run("GET /attempts with JWT returns 401 (admin-only)", func() { - resp, err := suite.client.Do(suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/attempts", - }, jwtToken)) - suite.Require().NoError(err) - suite.Equal(http.StatusUnauthorized, resp.StatusCode) - }) - }) - - // ========================================================================= - // Cross-tenant query tests - // ========================================================================= - suite.Run("cross_tenant", func() { - suite.Run("GET /events returns events from all tenants", func() { - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/events", - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - body := resp.Body.(map[string]interface{}) - models := body["models"].([]interface{}) - // Should have at least 2 events (one from each tenant we created) - suite.GreaterOrEqual(len(models), 2) - - // Verify we have events from both tenants by checking event IDs - eventsSeen := map[string]bool{} - for _, item := range models { - event := item.(map[string]interface{}) - if id, ok := event["id"].(string); ok { - eventsSeen[id] = true - } - } - suite.True(eventsSeen[event1ID], "should include tenant1 event") - suite.True(eventsSeen[event2ID], "should include tenant2 event") - }) - - suite.Run("GET /attempts returns attempts from all tenants", func() { - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/attempts?include=event", - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - body := resp.Body.(map[string]interface{}) - models := body["models"].([]interface{}) - // Should have at least 2 attempts (one from each tenant we created) - suite.GreaterOrEqual(len(models), 2) - - // Verify we have attempts from both tenants by checking event IDs - eventsSeen := map[string]bool{} - for _, item := range models { - attempt := item.(map[string]interface{}) - if event, ok := attempt["event"].(map[string]interface{}); ok { - if id, ok := event["id"].(string); ok { - eventsSeen[id] = true - } - } - } - suite.True(eventsSeen[event1ID], "should include tenant1 attempt") - suite.True(eventsSeen[event2ID], "should include tenant2 attempt") - }) - }) - - // ========================================================================= - // tenant_id filter tests - // ========================================================================= - suite.Run("tenant_id_filter", func() { - suite.Run("GET /events?tenant_id=X filters to single tenant", func() { - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/events?tenant_id=" + tenant1ID, - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - body := resp.Body.(map[string]interface{}) - models := body["models"].([]interface{}) - suite.Len(models, 1) - - // Verify only tenant1 event by ID - event := models[0].(map[string]interface{}) - suite.Equal(event1ID, event["id"]) - }) - - suite.Run("GET /attempts?tenant_id=X filters to single tenant", func() { - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/attempts?tenant_id=" + tenant2ID + "&include=event", - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - body := resp.Body.(map[string]interface{}) - models := body["models"].([]interface{}) - suite.Len(models, 1) - - // Verify only tenant2 attempt by event ID - attempt := models[0].(map[string]interface{}) - event := attempt["event"].(map[string]interface{}) - suite.Equal(event2ID, event["id"]) - }) - }) - - // Cleanup - cleanupTests := []APITest{ - { - Name: "cleanup mock server tenant1", - Request: httpclient.Request{ - Method: httpclient.MethodDELETE, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations/" + destination1ID, - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{StatusCode: http.StatusOK}, - }, - }, - { - Name: "cleanup mock server tenant2", - Request: httpclient.Request{ - Method: httpclient.MethodDELETE, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations/" + destination2ID, - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{StatusCode: http.StatusOK}, - }, - }, - { - Name: "cleanup tenant1", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodDELETE, - Path: "/tenants/" + tenant1ID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{StatusCode: http.StatusOK}, - }, - }, - { - Name: "cleanup tenant2", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodDELETE, - Path: "/tenants/" + tenant2ID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{StatusCode: http.StatusOK}, - }, - }, - } - suite.RunAPITests(suite.T(), cleanupTests) -} From 928e225ed90015f8f8d2814bfdbd3ef448fb24e7 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sun, 1 Feb 2026 05:10:36 +0700 Subject: [PATCH 27/34] test: comprehensive e2e suite --- cmd/e2e/alert_test.go | 321 ------ cmd/e2e/alerts_test.go | 84 ++ cmd/e2e/api_test.go | 68 -- cmd/e2e/delivery_pipeline_test.go | 154 +++ cmd/e2e/destwebhook_test.go | 1493 ---------------------------- cmd/e2e/health_test.go | 13 + cmd/e2e/helpers_test.go | 540 ++++++++++ cmd/e2e/httpclient/httpclient.go | 214 ---- cmd/e2e/log_queries_test.go | 336 +++++++ cmd/e2e/log_test.go | 791 --------------- cmd/e2e/regressions_test.go | 306 ++++++ cmd/e2e/retry_test.go | 119 +++ cmd/e2e/signatures_test.go | 244 +++++ cmd/e2e/suites_test.go | 512 +--------- go.mod | 2 - go.sum | 4 - internal/util/testutil/testutil.go | 4 +- 17 files changed, 1805 insertions(+), 3400 deletions(-) delete mode 100644 cmd/e2e/alert_test.go create mode 100644 cmd/e2e/alerts_test.go delete mode 100644 cmd/e2e/api_test.go create mode 100644 cmd/e2e/delivery_pipeline_test.go delete mode 100644 cmd/e2e/destwebhook_test.go create mode 100644 cmd/e2e/health_test.go create mode 100644 cmd/e2e/helpers_test.go delete mode 100644 cmd/e2e/httpclient/httpclient.go create mode 100644 cmd/e2e/log_queries_test.go delete mode 100644 cmd/e2e/log_test.go create mode 100644 cmd/e2e/regressions_test.go create mode 100644 cmd/e2e/retry_test.go create mode 100644 cmd/e2e/signatures_test.go diff --git a/cmd/e2e/alert_test.go b/cmd/e2e/alert_test.go deleted file mode 100644 index 406574b8..00000000 --- a/cmd/e2e/alert_test.go +++ /dev/null @@ -1,321 +0,0 @@ -package e2e_test - -import ( - "fmt" - "net/http" - "time" - - "github.com/hookdeck/outpost/cmd/e2e/httpclient" - "github.com/hookdeck/outpost/internal/idgen" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func (suite *basicSuite) TestConsecutiveFailuresAlert() { - tenantID := idgen.String() - destinationID := idgen.Destination() - secret := "testsecret1234567890abcdefghijklmnop" - - tests := []APITest{ - { - Name: "PUT /tenants/:tenantID - Create tenant", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "PUT mockserver/destinations", - Request: httpclient.Request{ - Method: httpclient.MethodPUT, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations", - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destinationID), - }, - "credentials": map[string]interface{}{ - "secret": secret, - }, - }, - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destinationID), - }, - "credentials": map[string]interface{}{ - "secret": secret, - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - } - suite.RunAPITests(suite.T(), tests) - - // Add 20 event publish requests that will fail - tests = []APITest{} - for i := 0; i < 20; i++ { - tests = append(tests, APITest{ - Name: fmt.Sprintf("POST /publish - Publish event %d", i+1), - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/publish", - Body: map[string]interface{}{ - "tenant_id": tenantID, - "topic": "user.created", - "eligible_for_retry": false, - "metadata": map[string]any{ - "meta": "data", - "should_err": "true", - }, - "data": map[string]any{ - "index": i, - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusAccepted, - }, - }, - }) - } - suite.RunAPITests(suite.T(), tests) - - // Wait for destination to be disabled (polls until disabled_at is set) - suite.waitForDestinationDisabled(suite.T(), tenantID, destinationID, 5*time.Second) - - // Verify destination is disabled - tests = []APITest{ - { - Name: "GET /tenants/:tenantID/destinations/:destinationID - Check disabled", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - }), - Expected: APITestExpectation{ - Validate: makeDestinationDisabledValidator(destinationID, true), - }, - }, - } - suite.RunAPITests(suite.T(), tests) - - // Assert alerts were received - alerts := suite.alertServer.GetAlertsForDestination(destinationID) - require.Len(suite.T(), alerts, 4, "should have 4 alerts") - - expectedCounts := []int{10, 14, 18, 20} - for i, alert := range alerts { - assert.Equal(suite.T(), fmt.Sprintf("Bearer %s", suite.config.APIKey), alert.AuthHeader, "auth header should match") - assert.Equal(suite.T(), expectedCounts[i], alert.Alert.Data.ConsecutiveFailures, - "alert %d should have %d consecutive failures", i, expectedCounts[i]) - } -} - -func (suite *basicSuite) TestConsecutiveFailuresAlertReset() { - tenantID := idgen.String() - destinationID := idgen.Destination() - secret := "testsecret1234567890abcdefghijklmnop" - - // Setup phase - same as before - tests := []APITest{ - { - Name: "PUT /tenants/:tenantID - Create tenant", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "PUT mockserver/destinations", - Request: httpclient.Request{ - Method: httpclient.MethodPUT, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations", - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destinationID), - }, - "credentials": map[string]interface{}{ - "secret": secret, - }, - }, - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destinationID), - }, - "credentials": map[string]interface{}{ - "secret": secret, - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - } - suite.RunAPITests(suite.T(), tests) - - // First batch - 14 failures - tests = []APITest{} - for i := 0; i < 14; i++ { - tests = append(tests, APITest{ - Name: fmt.Sprintf("POST /publish - Publish failing event %d", i+1), - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/publish", - Body: map[string]interface{}{ - "tenant_id": tenantID, - "topic": "user.created", - "eligible_for_retry": false, - "metadata": map[string]any{ - "meta": "data", - "should_err": "true", - }, - "data": map[string]any{ - "index": i, - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusAccepted, - }, - }, - }) - } - - // One successful delivery - tests = append(tests, APITest{ - Delay: time.Second, - Name: "POST /publish - Publish successful event", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/publish", - Body: map[string]interface{}{ - "tenant_id": tenantID, - "topic": "user.created", - "eligible_for_retry": false, - "metadata": map[string]any{ - "meta": "data", - "should_err": "false", - }, - "data": map[string]any{ - "success": true, - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusAccepted, - }, - }, - }) - - // Second batch - 14 more failures - for i := 0; i < 14; i++ { - tests = append(tests, APITest{ - Name: fmt.Sprintf("POST /publish - Publish failing event %d (second batch)", i+1), - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/publish", - Body: map[string]interface{}{ - "tenant_id": tenantID, - "topic": "user.created", - "eligible_for_retry": false, - "metadata": map[string]any{ - "meta": "data", - "should_err": "true", - }, - "data": map[string]any{ - "index": i, - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusAccepted, - }, - }, - }) - } - suite.RunAPITests(suite.T(), tests) - - // Add final check for destination disabled state - tests = []APITest{} - tests = append(tests, APITest{ - Delay: time.Second / 2, - Name: "GET /tenants/:tenantID/destinations/:destinationID - Check disabled", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - }), - Expected: APITestExpectation{ - Validate: makeDestinationDisabledValidator(destinationID, false), - }, - }) - suite.RunAPITests(suite.T(), tests) - - // Assert alerts were received - alerts := suite.alertServer.GetAlertsForDestination(destinationID) - require.Len(suite.T(), alerts, 4, "should have 4 alerts") - - // First batch should have alerts at 10, 14 - // Second batch should have alerts at 10, 14 (after reset) - expectedCounts := []int{10, 14, 10, 14} - for i, alert := range alerts { - assert.Equal(suite.T(), fmt.Sprintf("Bearer %s", suite.config.APIKey), alert.AuthHeader, "auth header should match") - assert.Equal(suite.T(), expectedCounts[i], alert.Alert.Data.ConsecutiveFailures, - "alert %d should have %d consecutive failures", i, expectedCounts[i]) - } -} diff --git a/cmd/e2e/alerts_test.go b/cmd/e2e/alerts_test.go new file mode 100644 index 00000000..efecff78 --- /dev/null +++ b/cmd/e2e/alerts_test.go @@ -0,0 +1,84 @@ +package e2e_test + +import "fmt" + +func (s *basicSuite) TestAlerts_ConsecutiveFailuresTriggerAlertCallback() { + tenant := s.createTenant() + dest := s.createWebhookDestination(tenant.ID, "*", withSecret(testSecret)) + + // Publish 20 failing events + for i := 0; i < 20; i++ { + s.publish(tenant.ID, "user.created", map[string]any{ + "index": i, + }, withPublishMetadata(map[string]string{"should_err": "true"})) + } + + // Wait for destination to be disabled (sync point for all 20 deliveries) + s.waitForNewDestinationDisabled(tenant.ID, dest.ID) + + // Verify destination is disabled + got := s.getDestination(tenant.ID, dest.ID) + s.NotNil(got.DisabledAt, "destination should be disabled") + + // Wait for 4 alert callbacks to be processed + s.waitForAlerts(dest.ID, 4) + alerts := s.alertServer.GetAlertsForDestination(dest.ID) + s.Require().Len(alerts, 4, "should have 4 alerts") + + expectedCounts := []int{10, 14, 18, 20} + for i, alert := range alerts { + s.Equal(fmt.Sprintf("Bearer %s", s.config.APIKey), alert.AuthHeader, "auth header should match") + s.Equal(expectedCounts[i], alert.Alert.Data.ConsecutiveFailures, + "alert %d should have %d consecutive failures", i, expectedCounts[i]) + } +} + +func (s *basicSuite) TestAlerts_SuccessResetsConsecutiveFailureCounter() { + tenant := s.createTenant() + dest := s.createWebhookDestination(tenant.ID, "*", withSecret(testSecret)) + + // First batch: 14 failures + for i := 0; i < 14; i++ { + s.publish(tenant.ID, "user.created", map[string]any{ + "index": i, + }, withPublishMetadata(map[string]string{"should_err": "true"})) + } + + // Wait for first batch to be fully delivered + s.waitForNewMockServerEvents(dest.mockID, 14) + + // One successful delivery (resets counter) + s.publish(tenant.ID, "user.created", map[string]any{ + "success": true, + }, withPublishMetadata(map[string]string{"should_err": "false"})) + + // Wait for success event to be delivered + s.waitForNewMockServerEvents(dest.mockID, 15) + + // Second batch: 14 more failures + for i := 0; i < 14; i++ { + s.publish(tenant.ID, "user.created", map[string]any{ + "index": i, + }, withPublishMetadata(map[string]string{"should_err": "true"})) + } + + // Wait for all 29 deliveries + s.waitForNewMockServerEvents(dest.mockID, 29) + + // Destination should NOT be disabled (only 14 consecutive, threshold is 20) + got := s.getDestination(tenant.ID, dest.ID) + s.Nil(got.DisabledAt, "destination should NOT be disabled (counter reset after success)") + + // Wait for 4 alert callbacks: [10, 14] from first batch, [10, 14] from second batch + s.waitForAlerts(dest.ID, 4) + alerts := s.alertServer.GetAlertsForDestination(dest.ID) + s.Require().Len(alerts, 4, "should have 4 alerts") + + expectedCounts := []int{10, 14, 10, 14} + for i, alert := range alerts { + s.Equal(fmt.Sprintf("Bearer %s", s.config.APIKey), alert.AuthHeader, "auth header should match") + s.Equal(expectedCounts[i], alert.Alert.Data.ConsecutiveFailures, + "alert %d should have %d consecutive failures", i, expectedCounts[i]) + } +} + diff --git a/cmd/e2e/api_test.go b/cmd/e2e/api_test.go deleted file mode 100644 index b4ccd471..00000000 --- a/cmd/e2e/api_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package e2e_test - -import ( - "net/http" - - "github.com/hookdeck/outpost/cmd/e2e/httpclient" -) - -func (suite *basicSuite) TestHealthzAPI() { - tests := []APITest{ - { - Name: "GET /healthz", - Request: httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/healthz", - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - Validate: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "status": map[string]interface{}{ - "type": "string", - }, - "timestamp": map[string]interface{}{ - "type": "string", - }, - "workers": map[string]interface{}{ - "type": "object", - }, - }, - }, - }, - }, - } - suite.RunAPITests(suite.T(), tests) -} - -func makeDestinationDisabledValidator(id string, disabled bool) map[string]any { - var disabledValidator map[string]any - if disabled { - disabledValidator = map[string]any{ - "type": "string", - "minLength": 1, - } - } else { - disabledValidator = map[string]any{ - "type": "null", - } - } - return map[string]interface{}{ - "properties": map[string]interface{}{ - "statusCode": map[string]interface{}{ - "const": 200, - }, - "body": map[string]interface{}{ - "properties": map[string]interface{}{ - "id": map[string]interface{}{ - "const": id, - }, - "disabled_at": disabledValidator, - }, - }, - }, - } -} diff --git a/cmd/e2e/delivery_pipeline_test.go b/cmd/e2e/delivery_pipeline_test.go new file mode 100644 index 00000000..475527b7 --- /dev/null +++ b/cmd/e2e/delivery_pipeline_test.go @@ -0,0 +1,154 @@ +package e2e_test + +import ( + "time" + + "github.com/hookdeck/outpost/internal/idgen" +) + +func (s *basicSuite) TestDeliveryPipeline_PublishDeliversToWebhook() { + tenant := s.createTenant() + dest := s.createWebhookDestination(tenant.ID, "*", withSecret(testSecret)) + + s.publish(tenant.ID, "user.created", map[string]any{ + "event_id": "delivery_test_1", + }) + + // Verify mock server received the event + events := s.waitForNewMockServerEvents(dest.mockID, 1) + s.Require().Len(events, 1) + s.True(events[0].Success, "delivery should succeed") + s.True(events[0].Verified, "signature should be verified") + s.Equal("delivery_test_1", events[0].Payload["event_id"]) + + // Verify attempt was logged + attempts := s.waitForNewAttempts(tenant.ID, 1) + s.Require().GreaterOrEqual(len(attempts), 1) + first := attempts[0] + s.NotEmpty(first["id"]) + s.Equal(dest.ID, first["destination"]) + s.NotEmpty(first["status"]) +} + +func (s *basicSuite) TestDeliveryPipeline_PublishRespectsDataFilter() { + tenant := s.createTenant() + dest := s.createWebhookDestination(tenant.ID, "*", + withSecret(testSecret), + withFilter(map[string]any{ + "data": map[string]any{ + "amount": map[string]any{ + "$gte": 100, + }, + }, + }), + ) + + // Publish matching event (amount >= 100) + s.publish(tenant.ID, "user.created", map[string]any{ + "event_id": "filter_match", + "amount": 150, + }) + + events := s.waitForNewMockServerEvents(dest.mockID, 1) + s.Require().Len(events, 1) + s.True(events[0].Success) + s.True(events[0].Verified) + s.Equal("filter_match", events[0].Payload["event_id"]) + + // Clear events, then publish non-matching (amount < 100) + s.clearMockServerEvents(dest.mockID) + + s.publish(tenant.ID, "user.created", map[string]any{ + "event_id": "filter_no_match", + "amount": 50, + }) + + // Publish another matching event to prove the pipeline is active + // (rather than just being slow). + s.publish(tenant.ID, "user.created", map[string]any{ + "event_id": "filter_proof", + "amount": 200, + }) + + events = s.waitForNewMockServerEvents(dest.mockID, 1) + s.Require().Len(events, 1) + s.Equal("filter_proof", events[0].Payload["event_id"], + "only the matching event should be delivered; non-matching event was filtered") +} + +func (s *basicSuite) TestDeliveryPipeline_DisabledDestinationSkipsDelivery() { + tenant := s.createTenant() + dest := s.createWebhookDestination(tenant.ID, "*", withSecret(testSecret)) + + // Disable the destination + s.disableDestination(tenant.ID, dest.ID) + + // Publish — should NOT be delivered + s.publish(tenant.ID, "user.created", map[string]any{ + "event_id": "disabled_test", + }) + + s.assertNoDelivery(dest.mockID, 500*time.Millisecond) +} + +func (s *basicSuite) TestDeliveryPipeline_MultipleDestinationsEachReceiveDelivery() { + tenant := s.createTenant() + dest1 := s.createWebhookDestination(tenant.ID, "*", withSecret(testSecret)) + dest2 := s.createWebhookDestination(tenant.ID, "*", withSecret(testSecret)) + + s.publish(tenant.ID, "user.created", map[string]any{ + "event_id": "multi_dest_test", + }) + + // Both destinations should receive the event + events1 := s.waitForNewMockServerEvents(dest1.mockID, 1) + events2 := s.waitForNewMockServerEvents(dest2.mockID, 1) + + s.Require().Len(events1, 1) + s.Require().Len(events2, 1) + s.Equal("multi_dest_test", events1[0].Payload["event_id"]) + s.Equal("multi_dest_test", events2[0].Payload["event_id"]) +} + +func (s *basicSuite) TestDeliveryPipeline_DuplicateEventPublishReturnsDuplicate() { + tenant := s.createTenant() + s.createWebhookDestination(tenant.ID, "*") + + eventID := idgen.Event() + + resp1 := s.publish(tenant.ID, "user.created", map[string]any{ + "event_id": "dup_test", + }, withEventID(eventID)) + s.False(resp1.Duplicate, "first publish should not be duplicate") + + resp2 := s.publish(tenant.ID, "user.created", map[string]any{ + "event_id": "dup_test", + }, withEventID(eventID)) + s.True(resp2.Duplicate, "second publish with same ID should be duplicate") +} + +func (s *basicSuite) TestDeliveryPipeline_EnableAfterDisableResumesDelivery() { + tenant := s.createTenant() + dest := s.createWebhookDestination(tenant.ID, "*") + + // Disable the destination + s.disableDestination(tenant.ID, dest.ID) + + // Publish — should NOT be delivered + s.publish(tenant.ID, "user.created", map[string]any{ + "event_id": "pre_enable", + }) + s.assertNoDelivery(dest.mockID, 500*time.Millisecond) + + // Re-enable + s.enableDestination(tenant.ID, dest.ID) + + // Publish — should be delivered + s.publish(tenant.ID, "user.created", map[string]any{ + "event_id": "post_enable", + }) + + events := s.waitForNewMockServerEvents(dest.mockID, 1) + s.Require().Len(events, 1) + s.Equal("post_enable", events[0].Payload["event_id"]) +} diff --git a/cmd/e2e/destwebhook_test.go b/cmd/e2e/destwebhook_test.go deleted file mode 100644 index 18b5833a..00000000 --- a/cmd/e2e/destwebhook_test.go +++ /dev/null @@ -1,1493 +0,0 @@ -package e2e_test - -import ( - "fmt" - "net/http" - "time" - - "github.com/hookdeck/outpost/cmd/e2e/httpclient" - "github.com/hookdeck/outpost/internal/idgen" - "github.com/stretchr/testify/require" -) - -func (suite *basicSuite) TestDestwebhookPublish() { - tenantID := idgen.String() - sampleDestinationID := idgen.Destination() - eventIDs := []string{ - idgen.Event(), - idgen.Event(), - idgen.Event(), - idgen.Event(), - } - secret := "testsecret1234567890abcdefghijklmnop" - newSecret := "testsecret0987654321zyxwvutsrqponm" - tests := []APITest{ - { - Name: "PUT /tenants/:tenantID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "PUT mockserver/destinations", - Request: httpclient.Request{ - Method: httpclient.MethodPUT, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations", - Body: map[string]interface{}{ - "id": sampleDestinationID, - "type": "webhook", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, sampleDestinationID), - }, - "credentials": map[string]interface{}{ - "secret": secret, - }, - }, - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "id": sampleDestinationID, - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, sampleDestinationID), - }, - "credentials": map[string]interface{}{ - "secret": secret, - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "POST /publish", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/publish", - Body: map[string]interface{}{ - "tenant_id": tenantID, - "topic": "user.created", - "eligible_for_retry": false, - "metadata": map[string]any{ - "meta": "data", - }, - "data": map[string]any{ - "event_id": eventIDs[0], - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusAccepted, - }, - }, - }, - { - WaitFor: &MockServerPoll{BaseURL: suite.mockServerBaseURL, DestID: sampleDestinationID, MinCount: 1, Timeout: 5 * time.Second}, - Name: "GET mockserver/destinations/:destinationID/events - verify signature", - Request: httpclient.Request{ - Method: httpclient.MethodGET, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations/" + sampleDestinationID + "/events", - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: []interface{}{ - map[string]interface{}{ - "success": true, - "verified": true, - "payload": map[string]interface{}{ - "event_id": eventIDs[0], - }, - }, - }, - }, - }, - }, - { - Name: "DELETE mockserver/destinations/:destinationID/events - clear events", - Request: httpclient.Request{ - Method: httpclient.MethodDELETE, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations/" + sampleDestinationID + "/events", - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "PUT mockserver/destinations - manual secret rotation", - Request: httpclient.Request{ - Method: httpclient.MethodPUT, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations", - Body: map[string]interface{}{ - "id": sampleDestinationID, - "type": "webhook", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, sampleDestinationID), - }, - "credentials": map[string]interface{}{ - "secret": newSecret, - "previous_secret": secret, - "previous_secret_invalid_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339), - }, - }, - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "POST /publish - after manual rotation", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/publish", - Body: map[string]interface{}{ - "tenant_id": tenantID, - "topic": "user.created", - "eligible_for_retry": false, - "metadata": map[string]any{ - "meta": "data", - }, - "data": map[string]any{ - "event_id": eventIDs[1], - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusAccepted, - }, - }, - }, - { - WaitFor: &MockServerPoll{BaseURL: suite.mockServerBaseURL, DestID: sampleDestinationID, MinCount: 1, Timeout: 5 * time.Second}, - Name: "GET mockserver/destinations/:destinationID/events - verify rotated signature", - Request: httpclient.Request{ - Method: httpclient.MethodGET, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations/" + sampleDestinationID + "/events", - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: []interface{}{ - map[string]interface{}{ - "success": true, - "verified": true, - "payload": map[string]interface{}{ - "event_id": eventIDs[1], - }, - }, - }, - }, - }, - }, - { - Name: "DELETE mockserver/destinations/:destinationID/events - clear events again", - Request: httpclient.Request{ - Method: httpclient.MethodDELETE, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations/" + sampleDestinationID + "/events", - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "PATCH /tenants/:tenantID/destinations - update outpost destination", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + sampleDestinationID, - Body: map[string]interface{}{ - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, sampleDestinationID), - }, - "credentials": map[string]interface{}{ - "secret": newSecret, - "previous_secret": secret, - "previous_secret_invalid_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339), - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "POST /publish - after outpost update", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/publish", - Body: map[string]interface{}{ - "tenant_id": tenantID, - "topic": "user.created", - "eligible_for_retry": false, - "metadata": map[string]any{ - "meta": "data", - }, - "data": map[string]any{ - "event_id": eventIDs[2], - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusAccepted, - }, - }, - }, - { - WaitFor: &MockServerPoll{BaseURL: suite.mockServerBaseURL, DestID: sampleDestinationID, MinCount: 1, Timeout: 5 * time.Second}, - Name: "GET mockserver/destinations/:destinationID/events - verify new signature", - Request: httpclient.Request{ - Method: httpclient.MethodGET, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations/" + sampleDestinationID + "/events", - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: []interface{}{ - map[string]interface{}{ - "success": true, - "verified": true, - "payload": map[string]interface{}{ - "event_id": eventIDs[2], - }, - }, - }, - }, - }, - }, - { - Name: "DELETE mockserver/destinations/:destinationID/events - clear events before wrong secret test", - Request: httpclient.Request{ - Method: httpclient.MethodDELETE, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations/" + sampleDestinationID + "/events", - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "PUT mockserver/destinations - update with wrong secret", - Request: httpclient.Request{ - Method: httpclient.MethodPUT, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations", - Body: map[string]interface{}{ - "id": sampleDestinationID, - "type": "webhook", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, sampleDestinationID), - }, - "credentials": map[string]interface{}{ - "secret": "wrong-secret", - }, - }, - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "POST /publish - with wrong secret", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/publish", - Body: map[string]interface{}{ - "tenant_id": tenantID, - "topic": "user.created", - "eligible_for_retry": false, - "metadata": map[string]any{ - "meta": "data", - }, - "data": map[string]any{ - "event_id": eventIDs[3], - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusAccepted, - }, - }, - }, - { - WaitFor: &MockServerPoll{BaseURL: suite.mockServerBaseURL, DestID: sampleDestinationID, MinCount: 1, Timeout: 5 * time.Second}, - Name: "GET mockserver/destinations/:destinationID/events - verify signature fails", - Request: httpclient.Request{ - Method: httpclient.MethodGET, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations/" + sampleDestinationID + "/events", - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: []interface{}{ - map[string]interface{}{ - "success": true, - "verified": false, - "payload": map[string]interface{}{ - "event_id": eventIDs[3], - }, - }, - }, - }, - }, - }, - { - Name: "DELETE mockserver/destinations/:destinationID", - Request: httpclient.Request{ - Method: httpclient.MethodDELETE, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations/" + sampleDestinationID, - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - } - suite.RunAPITests(suite.T(), tests) -} - -func (suite *basicSuite) TestDestwebhookSecretRotation() { - tenantID := idgen.String() - destinationID := idgen.Destination() - - // Setup tenant - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusCreated, resp.StatusCode) - - // Create destination without secret - resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destinationID), - }, - }, - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusCreated, resp.StatusCode) - - // Get initial secret and verify initial state - resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - dest := resp.Body.(map[string]interface{}) - creds, ok := dest["credentials"].(map[string]interface{}) - suite.Require().True(ok) - suite.Require().NotEmpty(creds["secret"]) - suite.Require().Nil(creds["previous_secret"]) - suite.Require().Nil(creds["previous_secret_invalid_at"]) - - initialSecret := creds["secret"].(string) - - // Rotate secret - resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - Body: map[string]interface{}{ - "credentials": map[string]interface{}{ - "rotate_secret": true, - }, - }, - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - // Get destination and verify rotated state - resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - dest = resp.Body.(map[string]interface{}) - creds, ok = dest["credentials"].(map[string]interface{}) - suite.Require().True(ok) - suite.Require().NotEmpty(creds["secret"]) - suite.Require().NotEmpty(creds["previous_secret"]) - suite.Require().NotEmpty(creds["previous_secret_invalid_at"]) - suite.Require().Equal(initialSecret, creds["previous_secret"]) - suite.Require().NotEqual(initialSecret, creds["secret"]) -} - -func (suite *basicSuite) TestDestwebhookTenantSecretManagement() { - tenantID := idgen.String() - destinationID := idgen.Destination() - - // First create tenant and get JWT token - createTenantTests := []APITest{ - { - Name: "PUT /tenants/:tenantID to create tenant", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - } - suite.RunAPITests(suite.T(), createTenantTests) - - // Get JWT token - tokenResp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/token", - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, tokenResp.StatusCode) - - bodyMap := tokenResp.Body.(map[string]interface{}) - token := bodyMap["token"].(string) - suite.Require().NotEmpty(token) - - // Run tenant-scoped tests - tests := []APITest{ - { - Name: "POST /tenants/:tenantID/destinations - attempt to create destination with secret (should fail)", - Request: suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destinationID), - }, - "credentials": map[string]interface{}{ - "secret": "any-secret", - }, - }, - }, token), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusUnprocessableEntity, - Body: map[string]interface{}{ - "message": "validation error", - "data": []interface{}{ - "credentials.secret is forbidden", - }, - }, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations - create destination without secret", - Request: suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destinationID), - }, - }, - }, token), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - } - suite.RunAPITests(suite.T(), tests) - - // Get initial secret and verify initial state - resp, err := suite.client.Do(suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - }, token)) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - dest := resp.Body.(map[string]interface{}) - creds := dest["credentials"].(map[string]interface{}) - initialSecret := creds["secret"].(string) - suite.Require().NotEmpty(initialSecret) - suite.Require().Nil(creds["previous_secret"]) - suite.Require().Nil(creds["previous_secret_invalid_at"]) - - // Continue with permission tests - permissionTests := []APITest{ - { - Name: "PATCH /tenants/:tenantID/destinations/:destinationID - attempt to update secret directly", - Request: suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - Body: map[string]interface{}{ - "credentials": map[string]interface{}{ - "secret": "new-secret", - }, - }, - }, token), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusUnprocessableEntity, - Body: map[string]interface{}{ - "message": "validation error", - "data": []interface{}{ - "credentials.secret is forbidden", - }, - }, - }, - }, - }, - { - Name: "PATCH /tenants/:tenantID/destinations/:destinationID - attempt to set previous_secret directly", - Request: suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - Body: map[string]interface{}{ - "credentials": map[string]interface{}{ - "previous_secret": "another-secret", - }, - }, - }, token), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusUnprocessableEntity, - Body: map[string]interface{}{ - "message": "validation error", - "data": []interface{}{ - "credentials.previous_secret is forbidden", - }, - }, - }, - }, - }, - { - Name: "PATCH /tenants/:tenantID/destinations/:destinationID - attempt to set previous_secret_invalid_at directly", - Request: suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - Body: map[string]interface{}{ - "credentials": map[string]interface{}{ - "previous_secret_invalid_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339), - }, - }, - }, token), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusUnprocessableEntity, - Body: map[string]interface{}{ - "message": "validation error", - "data": []interface{}{ - "credentials.previous_secret_invalid_at is forbidden", - }, - }, - }, - }, - }, - { - Name: "PATCH /tenants/:tenantID/destinations/:destinationID - rotate secret properly", - Request: suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - Body: map[string]interface{}{ - "credentials": map[string]interface{}{ - "rotate_secret": true, - }, - }, - }, token), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destinations/:destinationID - verify rotation worked", - Request: suite.AuthJWTRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - }, token), - Expected: APITestExpectation{ - Validate: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "statusCode": map[string]interface{}{ - "const": 200, - }, - "body": map[string]interface{}{ - "type": "object", - "required": []interface{}{"credentials"}, - "properties": map[string]interface{}{ - "credentials": map[string]interface{}{ - "type": "object", - "required": []interface{}{"secret", "previous_secret", "previous_secret_invalid_at"}, - "properties": map[string]interface{}{ - "secret": map[string]interface{}{ - "type": "string", - "minLength": 32, - "pattern": "^[a-zA-Z0-9]+$", - }, - "previous_secret": map[string]interface{}{ - "type": "string", - "const": initialSecret, - }, - "previous_secret_invalid_at": map[string]interface{}{ - "type": "string", - "format": "date-time", - "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}", - }, - }, - "additionalProperties": false, - }, - }, - }, - }, - }, - }, - }, - } - suite.RunAPITests(suite.T(), permissionTests) - - // Clean up using admin auth - cleanupTests := []APITest{ - { - Name: "DELETE /tenants/:tenantID to clean up", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodDELETE, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - } - suite.RunAPITests(suite.T(), cleanupTests) -} - -func (suite *basicSuite) TestDestwebhookAdminSecretManagement() { - tenantID := idgen.String() - destinationID := idgen.Destination() - secret := "testsecret1234567890abcdefghijklmnop" - newSecret := "testsecret0987654321zyxwvutsrqponm" - - // First group: Test all creation flows - createTests := []APITest{ - { - Name: "PUT /tenants/:tenantID to create tenant", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations - create destination without credentials", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "id": destinationID + "-1", - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destinationID), - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destinations/:destinationID - verify auto-generated secret", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID + "-1", - }), - Expected: APITestExpectation{ - Validate: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "statusCode": map[string]interface{}{ - "const": 200, - }, - "body": map[string]interface{}{ - "type": "object", - "required": []interface{}{"credentials"}, - "properties": map[string]interface{}{ - "credentials": map[string]interface{}{ - "type": "object", - "required": []interface{}{"secret"}, - "properties": map[string]interface{}{ - "secret": map[string]interface{}{ - "type": "string", - "minLength": 32, - "pattern": "^[a-zA-Z0-9]+$", - }, - }, - "additionalProperties": false, - }, - }, - }, - }, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations - create destination with secret", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "id": destinationID, // Use main destinationID for update tests - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destinationID), - }, - "credentials": map[string]interface{}{ - "secret": secret, - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destinations/:destinationID - verify custom secret", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "credentials": map[string]interface{}{ - "secret": secret, - }, - }, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations - attempt to create with rotate_secret", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "id": destinationID + "-3", - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destinationID), - }, - "credentials": map[string]interface{}{ - "rotate_secret": true, - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusUnprocessableEntity, - Body: map[string]interface{}{ - "message": "validation error", - "data": []interface{}{ - "credentials.rotate_secret failed invalid validation", - }, - }, - }, - }, - }, - } - suite.RunAPITests(suite.T(), createTests) - - updatedPreviousSecret := secret + "_2" - updatedPreviousSecretInvalidAt := time.Now().Add(24 * time.Hour).Format(time.RFC3339) - - // Second group: Test update flows using the destination with custom secret - updateTests := []APITest{ - { - Name: "PATCH /tenants/:tenantID/destinations/:destinationID - update secret directly", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - Body: map[string]interface{}{ - "credentials": map[string]interface{}{ - "secret": newSecret, - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destinations/:destinationID - verify secret updated", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "credentials": map[string]interface{}{ - "secret": newSecret, - }, - }, - }, - }, - }, - { - Name: "PATCH /tenants/:tenantID/destinations/:destinationID - attempt to set invalid previous_secret_invalid_at format", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - Body: map[string]interface{}{ - "credentials": map[string]interface{}{ - "secret": newSecret, - "previous_secret": secret, - "previous_secret_invalid_at": "invalid-date", - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusUnprocessableEntity, - Body: map[string]interface{}{ - "message": "validation error", - "data": []interface{}{ - "credentials.previous_secret_invalid_at failed pattern validation", - }, - }, - }, - }, - }, - { - Name: "PATCH /tenants/:tenantID/destinations/:destinationID - attempt to set previous_secret without invalid_at", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - Body: map[string]interface{}{ - "credentials": map[string]interface{}{ - "previous_secret": updatedPreviousSecret, - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "credentials": map[string]interface{}{ - "previous_secret": updatedPreviousSecret, - }, - }, - }, - }, - }, - { - Name: "PATCH /tenants/:tenantID/destinations/:destinationID - attempt to set previous_secret_invalid_at without previous_secret", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - Body: map[string]interface{}{ - "credentials": map[string]interface{}{ - "previous_secret_invalid_at": updatedPreviousSecretInvalidAt, - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: map[string]interface{}{ - "credentials": map[string]interface{}{ - "previous_secret": updatedPreviousSecret, - "previous_secret_invalid_at": updatedPreviousSecretInvalidAt, - }, - }, - }, - }, - }, - { - Name: "PATCH /tenants/:tenantID/destinations/:destinationID - overrides everything", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - Body: map[string]interface{}{ - "credentials": map[string]interface{}{ - "secret": newSecret, - "previous_secret": secret, - "previous_secret_invalid_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339), - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destinations/:destinationID - verify previous_secret set", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - }), - Expected: APITestExpectation{ - Validate: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "statusCode": map[string]interface{}{ - "const": 200, - }, - "body": map[string]interface{}{ - "type": "object", - "required": []interface{}{"credentials"}, - "properties": map[string]interface{}{ - "credentials": map[string]interface{}{ - "type": "object", - "required": []interface{}{"secret", "previous_secret", "previous_secret_invalid_at"}, - "properties": map[string]interface{}{ - "secret": map[string]interface{}{ - "type": "string", - "minLength": 32, - "pattern": "^[a-zA-Z0-9]+$", - }, - "previous_secret": map[string]interface{}{ - "type": "string", - "const": secret, - }, - "previous_secret_invalid_at": map[string]interface{}{ - "type": "string", - "format": "date-time", - "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}", - }, - }, - "additionalProperties": false, - }, - }, - }, - }, - }, - }, - }, - { - Name: "PATCH /tenants/:tenantID/destinations/:destinationID - rotate secret as admin", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - Body: map[string]interface{}{ - "credentials": map[string]interface{}{ - "rotate_secret": true, - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "PATCH /tenants/:tenantID/destinations/:destinationID - attempt to set previous_secret and previous_secret_invalid_at without secret", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - Body: map[string]interface{}{ - "credentials": map[string]interface{}{ - "secret": "", - "previous_secret": secret, - "previous_secret_invalid_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339), - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusUnprocessableEntity, - Body: map[string]interface{}{ - "message": "validation error", - "data": []interface{}{ - "credentials.secret is required", - }, - }, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destinations/:destinationID - verify rotation worked", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - }), - Expected: APITestExpectation{ - Validate: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "statusCode": map[string]interface{}{ - "const": 200, - }, - "body": map[string]interface{}{ - "type": "object", - "required": []interface{}{"credentials"}, - "properties": map[string]interface{}{ - "credentials": map[string]interface{}{ - "type": "object", - "required": []interface{}{"secret", "previous_secret", "previous_secret_invalid_at"}, - "properties": map[string]interface{}{ - "secret": map[string]interface{}{ - "type": "string", - "minLength": 32, - "pattern": "^[a-zA-Z0-9]+$", - }, - "previous_secret": map[string]interface{}{ - "type": "string", - "const": newSecret, - }, - "previous_secret_invalid_at": map[string]interface{}{ - "type": "string", - "format": "date-time", - "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}", - }, - }, - "additionalProperties": false, - }, - }, - }, - }, - }, - }, - }, - { - Name: "PATCH /tenants/:tenantID/destinations/:destinationID - admin unset previous_secret", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPATCH, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - Body: map[string]interface{}{ - "credentials": map[string]interface{}{ - "previous_secret": "", - "previous_secret_invalid_at": "", - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "GET /tenants/:tenantID/destinations/:destinationID - verify previous_secret was unset", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - }), - Expected: APITestExpectation{ - Validate: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "statusCode": map[string]interface{}{ - "const": 200, - }, - "body": map[string]interface{}{ - "type": "object", - "required": []interface{}{"credentials"}, - "properties": map[string]interface{}{ - "credentials": map[string]interface{}{ - "type": "object", - "required": []interface{}{"secret"}, - "properties": map[string]interface{}{ - "secret": map[string]interface{}{ - "type": "string", - "minLength": 32, - "pattern": "^[a-zA-Z0-9]+$", - }, - }, - "additionalProperties": false, - }, - }, - }, - }, - }, - }, - }, - } - suite.RunAPITests(suite.T(), updateTests) - - // Clean up - cleanupTests := []APITest{ - { - Name: "DELETE /tenants/:tenantID to clean up", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodDELETE, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - } - suite.RunAPITests(suite.T(), cleanupTests) -} - -func (suite *basicSuite) TestDestwebhookFilter() { - tenantID := idgen.String() - destinationID := idgen.Destination() - eventMatchID := idgen.Event() - eventNoMatchID := idgen.Event() - secret := "testsecret1234567890abcdefghijklmnop" - - tests := []APITest{ - { - Name: "PUT /tenants/:tenantID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "PUT mockserver/destinations", - Request: httpclient.Request{ - Method: httpclient.MethodPUT, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations", - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destinationID), - }, - "credentials": map[string]interface{}{ - "secret": secret, - }, - }, - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "POST /tenants/:tenantID/destinations - create destination with filter using $gte operator", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "topics": "*", - "filter": map[string]interface{}{ - "data": map[string]interface{}{ - "amount": map[string]interface{}{ - "$gte": 100, - }, - }, - }, - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destinationID), - }, - "credentials": map[string]interface{}{ - "secret": secret, - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "POST /publish - event matches filter (amount >= 100)", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/publish", - Body: map[string]interface{}{ - "tenant_id": tenantID, - "topic": "user.created", - "eligible_for_retry": false, - "data": map[string]any{ - "event_id": eventMatchID, - "amount": 150, // >= 100, matches filter - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusAccepted, - }, - }, - }, - { - WaitFor: &MockServerPoll{BaseURL: suite.mockServerBaseURL, DestID: destinationID, MinCount: 1, Timeout: 5 * time.Second}, - Name: "GET mockserver - verify event was delivered", - Request: httpclient.Request{ - Method: httpclient.MethodGET, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations/" + destinationID + "/events", - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: []interface{}{ - map[string]interface{}{ - "success": true, - "verified": true, - "payload": map[string]interface{}{ - "event_id": eventMatchID, - "amount": float64(150), - }, - }, - }, - }, - }, - }, - { - Name: "DELETE mockserver events - clear for next test", - Request: httpclient.Request{ - Method: httpclient.MethodDELETE, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations/" + destinationID + "/events", - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "POST /publish - event does NOT match filter (amount < 100)", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/publish", - Body: map[string]interface{}{ - "tenant_id": tenantID, - "topic": "user.created", - "eligible_for_retry": false, - "data": map[string]any{ - "event_id": eventNoMatchID, - "amount": 50, // < 100, doesn't match filter - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusAccepted, - }, - }, - }, - { - Delay: 500 * time.Millisecond, // Can't poll for absence, but 500ms is enough for processing - Name: "GET mockserver - verify event was NOT delivered (filter mismatch)", - Request: httpclient.Request{ - Method: httpclient.MethodGET, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations/" + destinationID + "/events", - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - Body: []interface{}{}, // empty - no events delivered - }, - }, - }, - { - Name: "DELETE /tenants/:tenantID to clean up", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodDELETE, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - } - suite.RunAPITests(suite.T(), tests) -} - -// TestDeliveryRetry tests that failed deliveries are scheduled for retry via RSMQ. -// This exercises the RSMQ Lua scripts that are known to fail with Dragonfly. -func (suite *basicSuite) TestDeliveryRetry() { - t := suite.T() - tenantID := idgen.String() - destinationID := idgen.Destination() - secret := "testsecret1234567890abcdefghijklmnop" - - // Setup: create tenant - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - })) - require.NoError(t, err) - require.Equal(t, http.StatusCreated, resp.StatusCode) - - // Setup: configure mock server destination - resp, err = suite.client.Do(httpclient.Request{ - Method: httpclient.MethodPUT, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations", - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destinationID), - }, - "credentials": map[string]interface{}{ - "secret": secret, - }, - }, - }) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) - - // Setup: create destination in outpost - resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destinationID), - }, - "credentials": map[string]interface{}{ - "secret": secret, - }, - }, - })) - require.NoError(t, err) - require.Equal(t, http.StatusCreated, resp.StatusCode) - - // Publish event with retry enabled and should_err to force failure - // This will trigger the RSMQ retry scheduler - resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/publish", - Body: map[string]interface{}{ - "tenant_id": tenantID, - "topic": "user.created", - "eligible_for_retry": true, // Enable retry - exercises RSMQ! - "metadata": map[string]any{ - "should_err": "true", // Force delivery to fail - }, - "data": map[string]any{ - "test": "retry", - }, - }, - })) - require.NoError(t, err) - require.Equal(t, http.StatusAccepted, resp.StatusCode) - - // Wait for retry to be scheduled and attempted (poll for at least 2 delivery attempts) - suite.waitForMockServerEvents(t, destinationID, 2, 5*time.Second) - - // Wait for attempts to be logged, then verify attempt_number increments on automated retry - suite.waitForAttempts(t, "/attempts?tenant_id="+tenantID, 2, 5*time.Second) - - atmResponse, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/attempts?tenant_id=" + tenantID + "&dir=asc", - })) - require.NoError(t, err) - require.Equal(t, http.StatusOK, atmResponse.StatusCode) - - atmBody := atmResponse.Body.(map[string]interface{}) - atmModels := atmBody["models"].([]interface{}) - require.GreaterOrEqual(t, len(atmModels), 2, "should have at least 2 attempts from automated retry") - - // Sorted asc by time: attempt_number should increment (0, 1, 2, ...) - for i, m := range atmModels { - attempt := m.(map[string]interface{}) - require.Equal(t, float64(i), attempt["attempt_number"], - "attempt %d should have attempt_number=%d (automated retry increments)", i, i) - } - - // Cleanup - resp, err = suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodDELETE, - Path: "/tenants/" + tenantID, - })) - require.NoError(t, err) - require.Equal(t, http.StatusOK, resp.StatusCode) -} diff --git a/cmd/e2e/health_test.go b/cmd/e2e/health_test.go new file mode 100644 index 00000000..42ab53a8 --- /dev/null +++ b/cmd/e2e/health_test.go @@ -0,0 +1,13 @@ +package e2e_test + +import "net/http" + +func (s *basicSuite) TestHealth_ServerReportsHealthy() { + var resp map[string]any + status := s.doJSON(http.MethodGet, s.apiURL("/healthz"), nil, &resp) + + s.Require().Equal(http.StatusOK, status) + s.NotEmpty(resp["status"], "status should be present") + s.NotEmpty(resp["timestamp"], "timestamp should be present") + s.NotNil(resp["workers"], "workers should be present") +} diff --git a/cmd/e2e/helpers_test.go b/cmd/e2e/helpers_test.go new file mode 100644 index 00000000..857f3034 --- /dev/null +++ b/cmd/e2e/helpers_test.go @@ -0,0 +1,540 @@ +package e2e_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/hookdeck/outpost/internal/idgen" +) + +const ( + testSecret = "testsecret1234567890abcdefghijklmnop" + testSecretAlt = "testsecret0987654321zyxwvutsrqponm" +) + +// envDuration reads a duration from an environment variable, falling back to a default. +func envDuration(key string, fallback time.Duration) time.Duration { + if v := os.Getenv(key); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + } + return fallback +} + +// Centralized poll timeouts — override via environment for slow CI. +var ( + mockServerPollTimeout = envDuration("E2E_MOCK_TIMEOUT", 10*time.Second) + attemptPollTimeout = envDuration("E2E_ATTEMPT_TIMEOUT", 10*time.Second) + alertPollTimeout = envDuration("E2E_ALERT_TIMEOUT", 10*time.Second) +) + +// ============================================================================= +// Response structs (test-specific, not reusing internal/models) +// ============================================================================= + +type tenantResponse struct { + ID string `json:"id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type destinationResponse struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + Type string `json:"type"` + Topics json.RawMessage `json:"topics"` + Config map[string]string `json:"config"` + Credentials map[string]string `json:"credentials"` + DisabledAt *string `json:"disabled_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type publishResponse struct { + ID string `json:"id"` + Duplicate bool `json:"duplicate"` +} + +type mockServerEvent struct { + Success bool `json:"success"` + Verified bool `json:"verified"` + Payload map[string]interface{} `json:"payload"` +} + +type tokenResponse struct { + Token string `json:"token"` + TenantID string `json:"tenant_id"` +} + +// ============================================================================= +// Mock destination wrapper +// ============================================================================= + +type webhookDestination struct { + destinationResponse + mockID string // destination ID on mock server +} + +// SetResponse reconfigures the mock server to return a specific HTTP status code. +func (d *webhookDestination) SetResponse(s *basicSuite, status int) { + s.T().Helper() + s.doJSON(http.MethodPut, s.mockServerURL()+"/destinations", map[string]any{ + "id": d.mockID, + "type": "webhook", + "config": map[string]any{ + "url": fmt.Sprintf("%s/webhook/%s", s.mockServerURL(), d.mockID), + }, + "response": map[string]any{ + "status": status, + }, + }, nil) +} + +// SetSecret updates the mock server's secret for signature verification. +func (d *webhookDestination) SetSecret(s *basicSuite, secret string) { + s.T().Helper() + s.doJSON(http.MethodPut, s.mockServerURL()+"/destinations", map[string]any{ + "id": d.mockID, + "type": "webhook", + "config": map[string]any{ + "url": fmt.Sprintf("%s/webhook/%s", s.mockServerURL(), d.mockID), + }, + "credentials": map[string]any{ + "secret": secret, + }, + }, nil) +} + +// SetCredentials updates the mock server's full credentials. +func (d *webhookDestination) SetCredentials(s *basicSuite, creds map[string]string) { + s.T().Helper() + credMap := make(map[string]any, len(creds)) + for k, v := range creds { + credMap[k] = v + } + s.doJSON(http.MethodPut, s.mockServerURL()+"/destinations", map[string]any{ + "id": d.mockID, + "type": "webhook", + "config": map[string]any{ + "url": fmt.Sprintf("%s/webhook/%s", s.mockServerURL(), d.mockID), + }, + "credentials": credMap, + }, nil) +} + +// ============================================================================= +// Internal HTTP helpers +// ============================================================================= + +// doJSON sends a request with admin API key auth. Returns status code. +// Fails test on transport/marshal errors. result can be nil to discard body. +func (s *basicSuite) doJSON(method, url string, body any, result any) int { + s.T().Helper() + return s.doJSONWithAuth(method, url, fmt.Sprintf("Bearer %s", s.config.APIKey), body, result) +} + +// doJSONWithToken sends a request with a specific Bearer token. +func (s *basicSuite) doJSONWithToken(method, url string, token string, body any, result any) int { + s.T().Helper() + return s.doJSONWithAuth(method, url, fmt.Sprintf("Bearer %s", token), body, result) +} + +// doJSONRaw sends a request without any auth header. +func (s *basicSuite) doJSONRaw(method, url string, body any, result any) int { + s.T().Helper() + return s.doJSONWithAuth(method, url, "", body, result) +} + +func (s *basicSuite) doJSONWithAuth(method, url string, authHeader string, body any, result any) int { + s.T().Helper() + + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + s.Require().NoError(err) + bodyReader = bytes.NewReader(b) + } + + req, err := http.NewRequest(method, url, bodyReader) + s.Require().NoError(err) + req.Header.Set("Content-Type", "application/json") + if authHeader != "" { + req.Header.Set("Authorization", authHeader) + } + + resp, err := s.httpClient.Do(req) + s.Require().NoError(err) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + s.Require().NoError(err) + + // Log response body on non-2xx when caller doesn't inspect it (aids CI debugging). + if result == nil && resp.StatusCode >= 400 && len(respBody) > 0 { + s.T().Logf("HTTP %d %s %s: %s", resp.StatusCode, method, url, respBody) + } + + if result != nil && len(respBody) > 0 { + s.Require().NoError(json.Unmarshal(respBody, result)) + } + + return resp.StatusCode +} + +// apiURL builds a full URL for the outpost API. +func (s *basicSuite) apiURL(path string) string { + return fmt.Sprintf("http://localhost:%d/api/v1%s", s.config.APIPort, path) +} + +// mockServerURL returns the mock server base URL. +func (s *basicSuite) mockServerURL() string { + return s.mockServerBaseURL +} + +// ============================================================================= +// Resource helpers +// ============================================================================= + +// createTenant creates a new tenant with a random ID. +func (s *basicSuite) createTenant() tenantResponse { + s.T().Helper() + id := idgen.String() + var resp tenantResponse + status := s.doJSON(http.MethodPut, s.apiURL("/tenants/"+id), nil, &resp) + s.Require().Equal(http.StatusCreated, status, "failed to create tenant %s", id) + return resp +} + +// createWebhookDestination registers on mock server and creates on outpost. +func (s *basicSuite) createWebhookDestination(tenantID, topic string, opts ...destOpt) *webhookDestination { + s.T().Helper() + + o := destOpts{} + for _, fn := range opts { + fn(&o) + } + + destID := idgen.Destination() + + // Register on mock server + mockBody := map[string]any{ + "id": destID, + "type": "webhook", + "config": map[string]any{ + "url": fmt.Sprintf("%s/webhook/%s", s.mockServerURL(), destID), + }, + } + if o.secret != "" { + mockBody["credentials"] = map[string]any{ + "secret": o.secret, + } + } + if o.responseStatus != 0 { + mockBody["response"] = map[string]any{ + "status": o.responseStatus, + } + } + + status := s.doJSONRaw(http.MethodPut, s.mockServerURL()+"/destinations", mockBody, nil) + s.Require().Equal(http.StatusOK, status, "failed to register mock destination %s", destID) + + // Create on outpost + outpostBody := map[string]any{ + "id": destID, + "type": "webhook", + "topics": topic, + "config": map[string]any{ + "url": fmt.Sprintf("%s/webhook/%s", s.mockServerURL(), destID), + }, + } + if o.secret != "" { + outpostBody["credentials"] = map[string]any{ + "secret": o.secret, + } + } + if o.filter != nil { + outpostBody["filter"] = o.filter + } + + var resp destinationResponse + status = s.doJSON(http.MethodPost, s.apiURL("/tenants/"+tenantID+"/destinations"), outpostBody, &resp) + s.Require().Equal(http.StatusCreated, status, "failed to create destination %s", destID) + + return &webhookDestination{ + destinationResponse: resp, + mockID: destID, + } +} + +// publish publishes an event. +func (s *basicSuite) publish(tenantID, topic string, data map[string]any, opts ...publishOpt) publishResponse { + s.T().Helper() + + o := publishOpts{} + for _, fn := range opts { + fn(&o) + } + + body := map[string]any{ + "tenant_id": tenantID, + "topic": topic, + "eligible_for_retry": o.eligibleForRetry, + "data": data, + } + if o.eventID != "" { + body["id"] = o.eventID + } + if o.metadata != nil { + body["metadata"] = o.metadata + } + if o.time != nil { + body["time"] = o.time.Format(time.RFC3339Nano) + } + + var resp publishResponse + status := s.doJSON(http.MethodPost, s.apiURL("/publish"), body, &resp) + s.Require().Equal(http.StatusAccepted, status, "failed to publish event") + return resp +} + +// jwtFor returns a JWT token for the given tenant. +func (s *basicSuite) jwtFor(tenantID string) string { + s.T().Helper() + var resp tokenResponse + status := s.doJSON(http.MethodGet, s.apiURL("/tenants/"+tenantID+"/token"), nil, &resp) + s.Require().Equal(http.StatusOK, status, "failed to get token for tenant %s", tenantID) + s.Require().NotEmpty(resp.Token) + return resp.Token +} + +// getDestination returns a destination. +func (s *basicSuite) getDestination(tenantID, destID string) destinationResponse { + s.T().Helper() + var resp destinationResponse + status := s.doJSON(http.MethodGet, s.apiURL(fmt.Sprintf("/tenants/%s/destinations/%s", tenantID, destID)), nil, &resp) + s.Require().Equal(http.StatusOK, status, "failed to get destination %s", destID) + return resp +} + +// updateDestination patches a destination. +func (s *basicSuite) updateDestination(tenantID, destID string, body map[string]any) destinationResponse { + s.T().Helper() + var resp destinationResponse + status := s.doJSON(http.MethodPatch, s.apiURL(fmt.Sprintf("/tenants/%s/destinations/%s", tenantID, destID)), body, &resp) + s.Require().Equal(http.StatusOK, status, "failed to update destination %s", destID) + return resp +} + +// disableDestination disables a destination. +func (s *basicSuite) disableDestination(tenantID, destID string) { + s.T().Helper() + status := s.doJSON(http.MethodPut, s.apiURL(fmt.Sprintf("/tenants/%s/destinations/%s/disable", tenantID, destID)), nil, nil) + s.Require().Equal(http.StatusOK, status, "failed to disable destination %s", destID) +} + +// enableDestination enables a destination. +func (s *basicSuite) enableDestination(tenantID, destID string) { + s.T().Helper() + status := s.doJSON(http.MethodPut, s.apiURL(fmt.Sprintf("/tenants/%s/destinations/%s/enable", tenantID, destID)), nil, nil) + s.Require().Equal(http.StatusOK, status, "failed to enable destination %s", destID) +} + +// retryEvent retries an event. Returns status code (caller asserts). +func (s *basicSuite) retryEvent(eventID, destID string) int { + s.T().Helper() + return s.doJSON(http.MethodPost, s.apiURL("/retry"), map[string]any{ + "event_id": eventID, + "destination_id": destID, + }, nil) +} + +// ============================================================================= +// Wait helpers +// ============================================================================= + +// waitForAttempts polls until at least minCount attempts exist for the tenant. +func (s *basicSuite) waitForNewAttempts(tenantID string, minCount int) []map[string]any { + s.T().Helper() + timeout := attemptPollTimeout + deadline := time.Now().Add(timeout) + var lastCount int + + for time.Now().Before(deadline) { + var resp struct { + Models []map[string]any `json:"models"` + } + status := s.doJSON(http.MethodGet, s.apiURL("/attempts?tenant_id="+tenantID), nil, &resp) + if status == http.StatusOK { + lastCount = len(resp.Models) + if lastCount >= minCount { + return resp.Models + } + } + time.Sleep(100 * time.Millisecond) + } + s.Require().FailNowf("timeout", "timed out waiting for %d attempts (got %d)", minCount, lastCount) + return nil +} + +// waitForMockServerEvents polls the mock server until at least minCount events exist. +func (s *basicSuite) waitForNewMockServerEvents(destID string, minCount int) []mockServerEvent { + s.T().Helper() + timeout := mockServerPollTimeout + deadline := time.Now().Add(timeout) + var lastCount int + + for time.Now().Before(deadline) { + events, ok := s.fetchMockServerEvents(destID) + if ok { + lastCount = len(events) + if lastCount >= minCount { + return events + } + } + time.Sleep(100 * time.Millisecond) + } + s.Require().FailNowf("timeout", "timed out waiting for %d mock events for %s (got %d)", minCount, destID, lastCount) + return nil +} + +// waitForDestinationDisabled polls until the destination has disabled_at set. +func (s *basicSuite) waitForNewDestinationDisabled(tenantID, destID string) { + s.T().Helper() + timeout := mockServerPollTimeout + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + dest := s.getDestination(tenantID, destID) + if dest.DisabledAt != nil { + return + } + time.Sleep(100 * time.Millisecond) + } + s.Require().FailNowf("timeout", "timed out waiting for destination %s to be disabled", destID) +} + +// waitForAlerts polls until at least count alerts exist for the destination. +func (s *basicSuite) waitForAlerts(destID string, count int) { + s.T().Helper() + timeout := alertPollTimeout + deadline := time.Now().Add(timeout) + var lastCount int + + for time.Now().Before(deadline) { + lastCount = len(s.alertServer.GetAlertsForDestination(destID)) + if lastCount >= count { + return + } + time.Sleep(100 * time.Millisecond) + } + s.Require().FailNowf("timeout", "timed out waiting for %d alerts for %s (got %d)", count, destID, lastCount) +} + +// ============================================================================= +// Absence assertion +// ============================================================================= + +// assertNoDelivery sleeps for the given duration then asserts the mock server +// received zero events for the destination. +func (s *basicSuite) assertNoDelivery(destID string, timeout time.Duration) { + s.T().Helper() + time.Sleep(timeout) + + events, ok := s.fetchMockServerEvents(destID) + if !ok { + // No events endpoint returned non-200 (e.g. 400 "no events found") — means zero events. + return + } + s.Require().Empty(events, "expected no deliveries for destination %s but got %d", destID, len(events)) +} + +// ============================================================================= +// Mock server helpers +// ============================================================================= + +// fetchMockServerEvents fetches events from the mock server without failing the +// test on non-200 responses. Returns (events, true) on success, (nil, false) on +// non-200 (e.g. 400 "no events found for destination"). +func (s *basicSuite) fetchMockServerEvents(destID string) ([]mockServerEvent, bool) { + resp, err := s.httpClient.Get(s.mockServerURL() + "/destinations/" + destID + "/events") + if err != nil { + return nil, false + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, false + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, false + } + var events []mockServerEvent + if err := json.Unmarshal(body, &events); err != nil { + return nil, false + } + return events, true +} + +// clearMockServerEvents clears events for a destination on the mock server. +func (s *basicSuite) clearMockServerEvents(destID string) { + s.T().Helper() + status := s.doJSONRaw(http.MethodDelete, s.mockServerURL()+"/destinations/"+destID+"/events", nil, nil) + s.Require().Equal(http.StatusOK, status, "failed to clear mock server events for %s", destID) +} + +// ============================================================================= +// Functional options +// ============================================================================= + +// Destination options +type destOpt func(*destOpts) + +type destOpts struct { + secret string + filter map[string]any + responseStatus int +} + +func withSecret(s string) destOpt { + return func(o *destOpts) { o.secret = s } +} + +func withFilter(f map[string]any) destOpt { + return func(o *destOpts) { o.filter = f } +} + +func withResponseStatus(code int) destOpt { + return func(o *destOpts) { o.responseStatus = code } +} + +// Publish options +type publishOpt func(*publishOpts) + +type publishOpts struct { + eventID string + eligibleForRetry bool + metadata map[string]string + time *time.Time +} + +func withEventID(id string) publishOpt { + return func(o *publishOpts) { o.eventID = id } +} + +func withRetry() publishOpt { + return func(o *publishOpts) { o.eligibleForRetry = true } +} + +func withPublishMetadata(m map[string]string) publishOpt { + return func(o *publishOpts) { o.metadata = m } +} + +func withTime(t time.Time) publishOpt { + return func(o *publishOpts) { o.time = &t } +} diff --git a/cmd/e2e/httpclient/httpclient.go b/cmd/e2e/httpclient/httpclient.go deleted file mode 100644 index e11a1f30..00000000 --- a/cmd/e2e/httpclient/httpclient.go +++ /dev/null @@ -1,214 +0,0 @@ -package httpclient - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "reflect" - "time" - - "github.com/google/go-cmp/cmp" -) - -const ( - MethodGET = "GET" - MethodPOST = "POST" - MethodPUT = "PUT" - MethodPATCH = "PATCH" - MethodDELETE = "DELETE" -) - -type Request struct { - BaseURL string - Method string - Path string - Body map[string]interface{} - Headers map[string]string -} - -func (r *Request) ToHTTPRequest(baseURL string) (*http.Request, error) { - if r.BaseURL != "" { - baseURL = r.BaseURL - } - var bodyReader io.Reader - if r.Body != nil { - jsonBody, err := json.Marshal(r.Body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(jsonBody) - } - request, err := http.NewRequest(r.Method, fmt.Sprintf("%s%s", baseURL, r.Path), bodyReader) - if err != nil { - return nil, err - } - for k, v := range r.Headers { - request.Header.Set(k, v) - } - return request, nil -} - -type ResponseBody = interface{} - -type Response struct { - StatusCode int `json:"statusCode"` - Body ResponseBody `json:"body"` -} - -func (r *Response) FromHTTPResponse(resp *http.Response) error { - r.StatusCode = resp.StatusCode - if resp.Body != nil { - defer resp.Body.Close() - json.NewDecoder(resp.Body).Decode(&r.Body) - } - return nil -} - -func (r *Response) MatchBody(body ResponseBody) bool { - return r.doMatchBody(r.Body, body) -} - -func (r *Response) doMatchBody(mainBody ResponseBody, toMatchedBody ResponseBody) bool { - if isSlice(mainBody) && isSlice(toMatchedBody) { - return r.sliceCmpEqual(mainBody, toMatchedBody) - } - mainBodyTyped, ok := mainBody.(map[string]interface{}) - if !ok { - return cmp.Equal(mainBody, toMatchedBody) - } - - toMatchedBodyTyped, ok := toMatchedBody.(map[string]interface{}) - if !ok { - return cmp.Equal(mainBody, toMatchedBody) - } - - for key, subValue := range toMatchedBodyTyped { - fullValue, ok := mainBodyTyped[key] - if !ok { - return false - } - - switch subValueTyped := subValue.(type) { - case map[string]interface{}: - fullValueTyped, ok := fullValue.(map[string]interface{}) - if !ok { - return false - } - if !r.doMatchBody(fullValueTyped, subValueTyped) { - return false - } - default: - if isSlice(subValue) && isSlice(fullValue) { - if !r.sliceCmpEqual(fullValue, subValue) { - return false - } - } else { - if !jsonCmpEqual(fullValue, subValue) { - log.Println("not equal", fullValue, subValue) - return false - } - } - } - } - return true -} - -func (r *Response) sliceCmpEqual(x, y interface{}) bool { - xTyped := convertToInterfaceSlice(x) - yTyped := convertToInterfaceSlice(y) - if len(xTyped) != len(yTyped) { - log.Println("failed slice comparison due to length") - return false - } - for i, yItem := range yTyped { - if !r.doMatchBody(xTyped[i], yItem) { - log.Println("failed slice comparison at index", i) - return false - } - } - return true -} - -func jsonCmpEqual(x, y interface{}) bool { - // Convert both values to JSON strings - xStr, err := json.Marshal(x) - if err != nil { - log.Println("Error marshaling x:", err) - return false - } - yStr, err := json.Marshal(y) - if err != nil { - log.Println("Error marshaling y:", err) - return false - } - - // Unmarshal JSON strings into interface{} - var xVal, yVal interface{} - if err := json.Unmarshal(xStr, &xVal); err != nil { - log.Println("Error unmarshaling x:", err) - return false - } - if err := json.Unmarshal(yStr, &yVal); err != nil { - log.Println("Error unmarshaling y:", err) - return false - } - - // Use reflect.DeepEqual to compare the unmarshaled values - return reflect.DeepEqual(xVal, yVal) -} - -func isSlice(value interface{}) bool { - v := reflect.ValueOf(value) - return v.Kind() == reflect.Slice -} - -func convertToInterfaceSlice(slice interface{}) []interface{} { - v := reflect.ValueOf(slice) - if v.Kind() != reflect.Slice { - return nil - } - result := make([]interface{}, v.Len()) - for i := 0; i < v.Len(); i++ { - result[i] = v.Index(i).Interface() - } - return result -} - -type Client interface { - Do(req Request) (Response, error) -} - -type client struct { - client *http.Client - baseURL string - apiKey string -} - -func (c *client) Do(req Request) (Response, error) { - httpReq, err := req.ToHTTPRequest(c.baseURL) - if err != nil { - return Response{}, err - } - httpResp, err := c.client.Do(httpReq) - if err != nil { - return Response{}, err - } - resp := Response{} - if err := resp.FromHTTPResponse(httpResp); err != nil { - return Response{}, err - } - return resp, nil -} - -func New(baseURL string, apiKey string) Client { - return &client{ - client: &http.Client{ - Timeout: 10 * time.Second, - }, - baseURL: baseURL, - apiKey: apiKey, - } -} diff --git a/cmd/e2e/log_queries_test.go b/cmd/e2e/log_queries_test.go new file mode 100644 index 00000000..79b30ea2 --- /dev/null +++ b/cmd/e2e/log_queries_test.go @@ -0,0 +1,336 @@ +package e2e_test + +import ( + "fmt" + "net/http" + "time" + + "github.com/hookdeck/outpost/internal/idgen" +) + +// parseTime parses a timestamp string (RFC3339 with optional nanoseconds). +// Panics if the string cannot be parsed (caught by the test framework as a failure). +func parseTime(s string) time.Time { + t, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + t, err = time.Parse(time.RFC3339, s) + if err != nil { + panic(fmt.Sprintf("parseTime: failed to parse %q: %v", s, err)) + } + } + return t +} + +// logQuerySetup holds shared state for log query tests. +type logQuerySetup struct { + tenantID string + destinationID string + eventIDs []string + baseTime time.Time +} + +func (s *basicSuite) setupLogQueryData() logQuerySetup { + s.T().Helper() + + tenant := s.createTenant() + dest := s.createWebhookDestination(tenant.ID, "*") + + // Generate 10 event IDs with readable prefix + eventPrefix := idgen.String()[:8] + eventIDs := make([]string, 10) + for i := range eventIDs { + eventIDs[i] = fmt.Sprintf("%s_event_%d", eventPrefix, i+1) + } + + // Publish 10 events with explicit timestamps (1 second apart) + baseTime := time.Now().Add(-1 * time.Hour).Truncate(time.Second) + for i, eventID := range eventIDs { + eventTime := baseTime.Add(time.Duration(i) * time.Second) + s.publish(tenant.ID, "user.created", map[string]any{ + "index": i, + }, withEventID(eventID), withTime(eventTime)) + } + + // Wait for all attempts + s.waitForNewAttempts(tenant.ID, 10) + + return logQuerySetup{ + tenantID: tenant.ID, + destinationID: dest.ID, + eventIDs: eventIDs, + baseTime: baseTime, + } +} + +func (s *basicSuite) TestLogQueries_Attempts() { + setup := s.setupLogQueryData() + + s.Run("list all", func() { + var resp struct { + Models []map[string]any `json:"models"` + } + status := s.doJSON(http.MethodGet, s.apiURL("/attempts?tenant_id="+setup.tenantID), nil, &resp) + s.Require().Equal(http.StatusOK, status) + s.Len(resp.Models, 10) + + first := resp.Models[0] + s.NotEmpty(first["id"]) + s.NotEmpty(first["event"]) + s.Equal(setup.destinationID, first["destination"]) + s.NotEmpty(first["status"]) + s.NotEmpty(first["delivered_at"]) + s.Equal(float64(0), first["attempt_number"]) + }) + + s.Run("filter by destination_id", func() { + var resp struct { + Models []map[string]any `json:"models"` + } + status := s.doJSON(http.MethodGet, s.apiURL("/attempts?tenant_id="+setup.tenantID+"&destination_id="+setup.destinationID), nil, &resp) + s.Require().Equal(http.StatusOK, status) + s.Len(resp.Models, 10) + }) + + s.Run("filter by event_id", func() { + var resp struct { + Models []map[string]any `json:"models"` + } + status := s.doJSON(http.MethodGet, s.apiURL("/attempts?tenant_id="+setup.tenantID+"&event_id="+setup.eventIDs[0]), nil, &resp) + s.Require().Equal(http.StatusOK, status) + s.Len(resp.Models, 1) + }) + + s.Run("include=event returns event object without data", func() { + var resp struct { + Models []map[string]any `json:"models"` + } + status := s.doJSON(http.MethodGet, s.apiURL("/attempts?tenant_id="+setup.tenantID+"&include=event&limit=1"), nil, &resp) + s.Require().Equal(http.StatusOK, status) + s.Require().Len(resp.Models, 1) + + event := resp.Models[0]["event"].(map[string]any) + s.NotEmpty(event["id"]) + s.NotEmpty(event["topic"]) + s.NotEmpty(event["time"]) + s.Nil(event["data"]) // include=event should NOT include data + }) + + s.Run("include=event.data returns event object with data", func() { + var resp struct { + Models []map[string]any `json:"models"` + } + status := s.doJSON(http.MethodGet, s.apiURL("/attempts?tenant_id="+setup.tenantID+"&include=event.data&limit=1"), nil, &resp) + s.Require().Equal(http.StatusOK, status) + s.Require().Len(resp.Models, 1) + + event := resp.Models[0]["event"].(map[string]any) + s.NotEmpty(event["id"]) + s.NotNil(event["data"]) // include=event.data SHOULD include data + }) + + s.Run("include=response_data returns response data", func() { + var resp struct { + Models []map[string]any `json:"models"` + } + status := s.doJSON(http.MethodGet, s.apiURL("/attempts?tenant_id="+setup.tenantID+"&include=response_data&limit=1"), nil, &resp) + s.Require().Equal(http.StatusOK, status) + s.Require().Len(resp.Models, 1) + s.NotNil(resp.Models[0]["response_data"]) + }) +} + +func (s *basicSuite) TestLogQueries_Events() { + setup := s.setupLogQueryData() + + s.Run("list all", func() { + var resp struct { + Models []map[string]any `json:"models"` + } + status := s.doJSON(http.MethodGet, s.apiURL("/events?tenant_id="+setup.tenantID), nil, &resp) + s.Require().Equal(http.StatusOK, status) + s.Len(resp.Models, 10) + + first := resp.Models[0] + s.NotEmpty(first["id"]) + s.NotEmpty(first["topic"]) + s.NotEmpty(first["time"]) + s.NotNil(first["data"]) + }) + + s.Run("filter by topic", func() { + var resp struct { + Models []map[string]any `json:"models"` + } + status := s.doJSON(http.MethodGet, s.apiURL("/events?tenant_id="+setup.tenantID+"&topic=user.created"), nil, &resp) + s.Require().Equal(http.StatusOK, status) + s.Len(resp.Models, 10) + }) + + s.Run("retrieve single event", func() { + var resp map[string]any + status := s.doJSON(http.MethodGet, s.apiURL("/events/"+setup.eventIDs[0]), nil, &resp) + s.Require().Equal(http.StatusOK, status) + s.Equal(setup.eventIDs[0], resp["id"]) + s.Equal("user.created", resp["topic"]) + s.NotNil(resp["data"]) + }) + + s.Run("retrieve non-existent event returns 404", func() { + status := s.doJSON(http.MethodGet, s.apiURL("/events/"+idgen.Event()), nil, nil) + s.Equal(http.StatusNotFound, status) + }) + + s.Run("filter by time[gte] excludes past events", func() { + futureTime := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339) + var resp struct { + Models []map[string]any `json:"models"` + } + status := s.doJSON(http.MethodGet, s.apiURL("/events?tenant_id="+setup.tenantID+"&time[gte]="+futureTime), nil, &resp) + s.Require().Equal(http.StatusOK, status) + s.Len(resp.Models, 0) + }) +} + +func (s *basicSuite) TestLogQueries_SortOrder() { + setup := s.setupLogQueryData() + + s.Run("events desc returns newest first", func() { + var resp struct { + Models []map[string]any `json:"models"` + } + status := s.doJSON(http.MethodGet, s.apiURL("/events?tenant_id="+setup.tenantID+"&dir=desc"), nil, &resp) + s.Require().Equal(http.StatusOK, status) + s.Require().Len(resp.Models, 10) + + for i := 0; i < len(resp.Models)-1; i++ { + curr := parseTime(resp.Models[i]["time"].(string)) + next := parseTime(resp.Models[i+1]["time"].(string)) + s.True(curr.After(next) || curr.Equal(next), "events not in descending order at index %d", i) + } + }) + + s.Run("events asc returns oldest first", func() { + var resp struct { + Models []map[string]any `json:"models"` + } + status := s.doJSON(http.MethodGet, s.apiURL("/events?tenant_id="+setup.tenantID+"&dir=asc"), nil, &resp) + s.Require().Equal(http.StatusOK, status) + s.Require().Len(resp.Models, 10) + + for i := 0; i < len(resp.Models)-1; i++ { + curr := parseTime(resp.Models[i]["time"].(string)) + next := parseTime(resp.Models[i+1]["time"].(string)) + s.True(curr.Before(next) || curr.Equal(next), "events not in ascending order at index %d", i) + } + }) + + s.Run("events invalid dir returns 422", func() { + status := s.doJSON(http.MethodGet, s.apiURL("/events?tenant_id="+setup.tenantID+"&dir=invalid"), nil, nil) + s.Equal(http.StatusUnprocessableEntity, status) + }) +} + +func (s *basicSuite) TestLogQueries_Pagination() { + setup := s.setupLogQueryData() + + s.Run("events limit=3 paginates correctly", func() { + var allEventIDs []string + nextCursor := "" + pageCount := 0 + + for { + path := "/events?tenant_id=" + setup.tenantID + "&limit=3&dir=asc" + if nextCursor != "" { + path += "&next=" + nextCursor + } + + var resp struct { + Models []map[string]any `json:"models"` + Pagination map[string]any `json:"pagination"` + } + status := s.doJSON(http.MethodGet, s.apiURL(path), nil, &resp) + s.Require().Equal(http.StatusOK, status) + pageCount++ + + for _, event := range resp.Models { + allEventIDs = append(allEventIDs, event["id"].(string)) + } + + if next, ok := resp.Pagination["next"].(string); ok && next != "" { + nextCursor = next + } else { + break + } + + if pageCount > 10 { + s.Fail("too many pages") + break + } + } + + s.Equal(4, pageCount, "expected 4 pages (3+3+3+1)") + s.Len(allEventIDs, 10, "should have all 10 events") + }) + + s.Run("cursor pagination with time filter", func() { + // Get all events to establish a time window + var allResp struct { + Models []map[string]any `json:"models"` + } + status := s.doJSON(http.MethodGet, s.apiURL("/events?tenant_id="+setup.tenantID+"&dir=asc&limit=10"), nil, &allResp) + s.Require().Equal(http.StatusOK, status) + s.Require().Len(allResp.Models, 10) + + // Use the 3rd and 7th events to create a time window + timeGTE := allResp.Models[2]["time"].(string) + timeLTE := allResp.Models[6]["time"].(string) + timeGTEParsed := parseTime(timeGTE) + timeLTEParsed := parseTime(timeLTE) + + // Paginate within the time window with limit=2 + var windowEvents []map[string]any + nextCursor := "" + pageCount := 0 + + for { + path := "/events?tenant_id=" + setup.tenantID + "&dir=asc&limit=2" + path += "&time[gte]=" + timeGTE + "&time[lte]=" + timeLTE + if nextCursor != "" { + path += "&next=" + nextCursor + } + + var resp struct { + Models []map[string]any `json:"models"` + Pagination map[string]any `json:"pagination"` + } + status := s.doJSON(http.MethodGet, s.apiURL(path), nil, &resp) + s.Require().Equal(http.StatusOK, status) + pageCount++ + + windowEvents = append(windowEvents, resp.Models...) + + if next, ok := resp.Pagination["next"].(string); ok && next != "" { + nextCursor = next + } else { + break + } + + if pageCount > 10 { + s.Fail("too many pages") + break + } + } + + // Verify time filter worked + s.Greater(len(windowEvents), 0, "should have some events in window") + s.Less(len(windowEvents), 10, "time filter should exclude some events") + s.Greater(pageCount, 1, "should require multiple pages") + + // Verify all returned events are within the time window + for _, event := range windowEvents { + eventTime := parseTime(event["time"].(string)) + s.True(!eventTime.Before(timeGTEParsed), "event time %v should be >= %v", eventTime, timeGTEParsed) + s.True(!eventTime.After(timeLTEParsed), "event time %v should be <= %v", eventTime, timeLTEParsed) + } + }) +} diff --git a/cmd/e2e/log_test.go b/cmd/e2e/log_test.go deleted file mode 100644 index f31beb9e..00000000 --- a/cmd/e2e/log_test.go +++ /dev/null @@ -1,791 +0,0 @@ -package e2e_test - -import ( - "fmt" - "net/http" - "time" - - "github.com/hookdeck/outpost/cmd/e2e/httpclient" - "github.com/hookdeck/outpost/internal/idgen" -) - -// parseTime parses a timestamp string (RFC3339 with optional nanoseconds) -func parseTime(s string) time.Time { - t, err := time.Parse(time.RFC3339Nano, s) - if err != nil { - t, _ = time.Parse(time.RFC3339, s) - } - return t -} - -// TestLogAPI tests the Log API endpoints (attempts, events). -// -// Setup: -// 1. Create a tenant and destination -// 2. Publish 10 events with small delays for distinct timestamps -// -// Test Groups: -// - attempts: list, filter, expand -// - events: list, filter, retrieve -// - sort_order: sort by time ascending/descending -// - pagination: paginate through results -func (suite *basicSuite) TestLogAPI() { - tenantID := idgen.String() - destinationID := idgen.Destination() - - // Generate 10 event IDs with readable numbers and unique prefix - eventPrefix := idgen.String()[:8] - eventIDs := make([]string, 10) - for i := range eventIDs { - eventIDs[i] = fmt.Sprintf("%s_event_%d", eventPrefix, i+1) - } - - // Setup: Create tenant and destination - setupTests := []APITest{ - { - Name: "create tenant", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{StatusCode: http.StatusCreated}, - }, - }, - { - Name: "setup mock server", - Request: httpclient.Request{ - Method: httpclient.MethodPUT, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations", - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destinationID), - }, - }, - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{StatusCode: http.StatusOK}, - }, - }, - { - Name: "create destination", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destinationID), - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{StatusCode: http.StatusCreated}, - }, - }, - } - suite.RunAPITests(suite.T(), setupTests) - - // Publish 10 events with explicit timestamps (1 second apart) - baseTime := time.Now().Add(-1 * time.Hour).Truncate(time.Second) - for i, eventID := range eventIDs { - eventTime := baseTime.Add(time.Duration(i) * time.Second) - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/publish", - Body: map[string]interface{}{ - "id": eventID, - "tenant_id": tenantID, - "topic": "user.created", - "eligible_for_retry": true, - "time": eventTime.Format(time.RFC3339Nano), - "data": map[string]interface{}{"index": i}, - }, - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusAccepted, resp.StatusCode, "failed to publish event %d", i) - } - - // Wait for all attempts (30s timeout for slow CI environments) - suite.waitForAttempts(suite.T(), "/attempts?tenant_id="+tenantID, 10, 10*time.Second) - - // ========================================================================= - // Attempts Tests - // ========================================================================= - suite.Run("attempts", func() { - suite.Run("list all", func() { - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/attempts?tenant_id=" + tenantID, - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - body := resp.Body.(map[string]interface{}) - models := body["models"].([]interface{}) - suite.Len(models, 10) - - // Verify structure - first := models[0].(map[string]interface{}) - suite.NotEmpty(first["id"]) - suite.NotEmpty(first["event"]) - suite.Equal(destinationID, first["destination"]) - suite.NotEmpty(first["status"]) - suite.NotEmpty(first["delivered_at"]) - suite.Equal(float64(0), first["attempt_number"], "attempt_number should be present and equal to 0 for first attempt") - }) - - suite.Run("filter by destination_id", func() { - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/attempts?tenant_id=" + tenantID + "&destination_id=" + destinationID, - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - body := resp.Body.(map[string]interface{}) - models := body["models"].([]interface{}) - suite.Len(models, 10) - }) - - suite.Run("filter by event_id", func() { - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/attempts?tenant_id=" + tenantID + "&event_id=" + eventIDs[0], - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - body := resp.Body.(map[string]interface{}) - models := body["models"].([]interface{}) - suite.Len(models, 1) - }) - - suite.Run("include=event returns event object without data", func() { - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/attempts?tenant_id=" + tenantID + "&include=event&limit=1", - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - body := resp.Body.(map[string]interface{}) - models := body["models"].([]interface{}) - suite.Require().Len(models, 1) - - attempt := models[0].(map[string]interface{}) - event := attempt["event"].(map[string]interface{}) - suite.NotEmpty(event["id"]) - suite.NotEmpty(event["topic"]) - suite.NotEmpty(event["time"]) - suite.Nil(event["data"]) // include=event should NOT include data - }) - - suite.Run("include=event.data returns event object with data", func() { - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/attempts?tenant_id=" + tenantID + "&include=event.data&limit=1", - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - body := resp.Body.(map[string]interface{}) - models := body["models"].([]interface{}) - suite.Require().Len(models, 1) - - attempt := models[0].(map[string]interface{}) - event := attempt["event"].(map[string]interface{}) - suite.NotEmpty(event["id"]) - suite.NotNil(event["data"]) // include=event.data SHOULD include data - }) - - suite.Run("include=response_data returns response data", func() { - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/attempts?tenant_id=" + tenantID + "&include=response_data&limit=1", - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - body := resp.Body.(map[string]interface{}) - models := body["models"].([]interface{}) - suite.Require().Len(models, 1) - - attempt := models[0].(map[string]interface{}) - suite.NotNil(attempt["response_data"]) - }) - }) - - // ========================================================================= - // Events Tests - // ========================================================================= - suite.Run("events", func() { - suite.Run("list all", func() { - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/events?tenant_id=" + tenantID, - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - body := resp.Body.(map[string]interface{}) - models := body["models"].([]interface{}) - suite.Len(models, 10) - - // Verify structure - first := models[0].(map[string]interface{}) - suite.NotEmpty(first["id"]) - suite.NotEmpty(first["topic"]) - suite.NotEmpty(first["time"]) - suite.NotNil(first["data"]) - }) - - suite.Run("filter by topic", func() { - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/events?tenant_id=" + tenantID + "&topic=user.created", - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - body := resp.Body.(map[string]interface{}) - models := body["models"].([]interface{}) - suite.Len(models, 10) // All events have topic=user.created - }) - - suite.Run("retrieve single event", func() { - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/events/" + eventIDs[0], - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - body := resp.Body.(map[string]interface{}) - suite.Equal(eventIDs[0], body["id"]) - suite.Equal("user.created", body["topic"]) - suite.NotNil(body["data"]) - }) - - suite.Run("retrieve non-existent event returns 404", func() { - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/events/" + idgen.Event(), - })) - suite.Require().NoError(err) - suite.Equal(http.StatusNotFound, resp.StatusCode) - }) - - suite.Run("filter by time[gte] excludes past events", func() { - futureTime := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339) - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/events?tenant_id=" + tenantID + "&time[gte]=" + futureTime, - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - body := resp.Body.(map[string]interface{}) - models := body["models"].([]interface{}) - suite.Len(models, 0) - }) - }) - - // ========================================================================= - // Sort Order Tests - // ========================================================================= - suite.Run("sort_order", func() { - suite.Run("events desc returns newest first", func() { - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/events?tenant_id=" + tenantID + "&dir=desc", - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - body := resp.Body.(map[string]interface{}) - models := body["models"].([]interface{}) - suite.Require().Len(models, 10) - - for i := 0; i < len(models)-1; i++ { - curr := parseTime(models[i].(map[string]interface{})["time"].(string)) - next := parseTime(models[i+1].(map[string]interface{})["time"].(string)) - suite.True(curr.After(next) || curr.Equal(next), "events not in descending order at index %d", i) - } - }) - - suite.Run("events asc returns oldest first", func() { - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/events?tenant_id=" + tenantID + "&dir=asc", - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - body := resp.Body.(map[string]interface{}) - models := body["models"].([]interface{}) - suite.Require().Len(models, 10) - - for i := 0; i < len(models)-1; i++ { - curr := parseTime(models[i].(map[string]interface{})["time"].(string)) - next := parseTime(models[i+1].(map[string]interface{})["time"].(string)) - suite.True(curr.Before(next) || curr.Equal(next), "events not in ascending order at index %d", i) - } - }) - - suite.Run("events invalid dir returns 422", func() { - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/events?tenant_id=" + tenantID + "&dir=invalid", - })) - suite.Require().NoError(err) - suite.Equal(http.StatusUnprocessableEntity, resp.StatusCode) - }) - - }) - - // ========================================================================= - // Pagination Tests - // ========================================================================= - suite.Run("pagination", func() { - suite.Run("events limit=3 paginates correctly", func() { - var allEventIDs []string - nextCursor := "" - pageCount := 0 - - for { - path := "/events?tenant_id=" + tenantID + "&limit=3&dir=asc" - if nextCursor != "" { - path += "&next=" + nextCursor - } - - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: path, - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - body := resp.Body.(map[string]interface{}) - models := body["models"].([]interface{}) - pageCount++ - - for _, item := range models { - event := item.(map[string]interface{}) - allEventIDs = append(allEventIDs, event["id"].(string)) - } - - pagination, _ := body["pagination"].(map[string]interface{}) - if next, ok := pagination["next"].(string); ok && next != "" { - nextCursor = next - } else { - break - } - - if pageCount > 10 { - suite.Fail("too many pages") - break - } - } - - suite.Equal(4, pageCount, "expected 4 pages (3+3+3+1)") - suite.Len(allEventIDs, 10, "should have all 10 events") - }) - - suite.Run("cursor pagination with time filter", func() { - // Get all events to establish a time window - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/events?tenant_id=" + tenantID + "&dir=asc&limit=10", - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - body := resp.Body.(map[string]interface{}) - models := body["models"].([]interface{}) - suite.Require().Len(models, 10) - - // Use the 3rd and 7th events to create a time window - event3 := models[2].(map[string]interface{}) - event7 := models[6].(map[string]interface{}) - timeGTE := event3["time"].(string) - timeLTE := event7["time"].(string) - timeGTEParsed := parseTime(timeGTE) - timeLTEParsed := parseTime(timeLTE) - - // Paginate within the time window with limit=2 - var windowEvents []map[string]interface{} - nextCursor := "" - pageCount := 0 - - for { - path := "/events?tenant_id=" + tenantID + "&dir=asc&limit=2" - path += "&time[gte]=" + timeGTE + "&time[lte]=" + timeLTE - if nextCursor != "" { - path += "&next=" + nextCursor - } - - resp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: path, - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, resp.StatusCode) - - body := resp.Body.(map[string]interface{}) - windowModels := body["models"].([]interface{}) - pageCount++ - - for _, item := range windowModels { - event := item.(map[string]interface{}) - windowEvents = append(windowEvents, event) - } - - pagination, _ := body["pagination"].(map[string]interface{}) - if next, ok := pagination["next"].(string); ok && next != "" { - nextCursor = next - } else { - break - } - - if pageCount > 10 { - suite.Fail("too many pages") - break - } - } - - // Verify time filter worked: should have fewer events than total - suite.Greater(len(windowEvents), 0, "should have some events in window") - suite.Less(len(windowEvents), 10, "time filter should exclude some events") - - // Verify pagination worked: multiple pages needed - suite.Greater(pageCount, 1, "should require multiple pages") - - // Verify all returned events are within the time window - for _, event := range windowEvents { - eventTime := parseTime(event["time"].(string)) - suite.True(!eventTime.Before(timeGTEParsed), "event time %v should be >= %v", eventTime, timeGTEParsed) - suite.True(!eventTime.After(timeLTEParsed), "event time %v should be <= %v", eventTime, timeLTEParsed) - } - }) - }) - - // Cleanup - cleanupTests := []APITest{ - { - Name: "cleanup mock server", - Request: httpclient.Request{ - Method: httpclient.MethodDELETE, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations/" + destinationID, - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{StatusCode: http.StatusOK}, - }, - }, - { - Name: "cleanup tenant", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodDELETE, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{StatusCode: http.StatusOK}, - }, - }, - } - suite.RunAPITests(suite.T(), cleanupTests) -} - -// TestRetryAPI tests the retry endpoint. -// -// Setup: -// 1. Create a tenant -// 2. Configure mock webhook server to FAIL (return 500) -// 3. Create a destination pointing to the mock server -// 4. Publish an event with eligible_for_retry=false (fails once, no auto-retry) -// 5. Wait for attempt to fail, then fetch the attempt ID -// 6. Update mock server to SUCCEED (return 200) -// -// Test Cases: -// - POST /retry - Successful retry returns 202 Accepted -// - POST /retry (non-existent event) - Returns 404 -// - Verify retry created new attempt - Event now has 2+ attempts -// - POST /retry (disabled destination) - Returns 400 -func (suite *basicSuite) TestRetryAPI() { - tenantID := idgen.String() - destinationID := idgen.Destination() - eventID := idgen.Event() - - // Setup: create tenant, destination with failing webhook, and publish event - setupTests := []APITest{ - { - Name: "PUT /:tenantID - create tenant", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "PUT mockserver/destinations - setup mock to fail", - Request: httpclient.Request{ - Method: httpclient.MethodPUT, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations", - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destinationID), - }, - "response": map[string]interface{}{ - "status": 500, // Fail attempts - }, - }, - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "POST /:tenantID/destinations - create destination", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destinationID), - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusCreated, - }, - }, - }, - { - Name: "POST /publish - publish event (will fail)", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/publish", - Body: map[string]interface{}{ - "id": eventID, - "tenant_id": tenantID, - "topic": "user.created", - "eligible_for_retry": false, // Disable auto-retry - "data": map[string]interface{}{ - "user_id": "456", - }, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusAccepted, - }, - }, - }, - } - suite.RunAPITests(suite.T(), setupTests) - - // Wait for attempt to complete (and fail) - suite.waitForAttempts(suite.T(), "/attempts?tenant_id="+tenantID+"&event_id="+eventID, 1, 5*time.Second) - - // Get the attempt ID - attemptsResp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/attempts?tenant_id=" + tenantID + "&event_id=" + eventID, - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, attemptsResp.StatusCode) - - body := attemptsResp.Body.(map[string]interface{}) - models := body["models"].([]interface{}) - suite.Require().NotEmpty(models, "should have at least one attempt") - firstAttempt := models[0].(map[string]interface{}) - - // Verify first attempt has attempt_number=0 - suite.Equal(float64(0), firstAttempt["attempt_number"], "first attempt should have attempt_number=0") - - // Update mock to succeed for retry - updateMockTests := []APITest{ - { - Name: "PUT mockserver/destinations - setup mock to succeed", - Request: httpclient.Request{ - Method: httpclient.MethodPUT, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations", - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", suite.mockServerBaseURL, destinationID), - }, - "response": map[string]interface{}{ - "status": 200, // Now succeed - }, - }, - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - } - suite.RunAPITests(suite.T(), updateMockTests) - - // Test retry endpoint - retryTests := []APITest{ - // POST /retry - successful retry - { - Name: "POST /retry - retry event", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/retry", - Body: map[string]interface{}{ - "event_id": eventID, - "destination_id": destinationID, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusAccepted, - Body: map[string]interface{}{ - "success": true, - }, - }, - }, - }, - // POST /retry - non-existent event - { - Name: "POST /retry - not found", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/retry", - Body: map[string]interface{}{ - "event_id": idgen.Event(), - "destination_id": destinationID, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusNotFound, - }, - }, - }, - } - suite.RunAPITests(suite.T(), retryTests) - - // Wait for retry attempt to complete - suite.waitForAttempts(suite.T(), "/attempts?tenant_id="+tenantID+"&event_id="+eventID, 2, 5*time.Second) - - // Verify retry created a new attempt with incremented attempt_number - verifyResp, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/attempts?tenant_id=" + tenantID + "&event_id=" + eventID + "&dir=asc", - })) - suite.Require().NoError(err) - suite.Require().Equal(http.StatusOK, verifyResp.StatusCode) - - verifyBody := verifyResp.Body.(map[string]interface{}) - verifyModels := verifyBody["models"].([]interface{}) - suite.Require().Len(verifyModels, 2, "should have original + retry attempt") - - // Both attempts should have attempt_number=0 (manual retry resets to 0) - for _, m := range verifyModels { - atm := m.(map[string]interface{}) - suite.Equal(float64(0), atm["attempt_number"], "attempt should have attempt_number=0") - } - - // Verify we have one manual=true (retry) and one manual=false (original) - manualCount := 0 - for _, m := range verifyModels { - atm := m.(map[string]interface{}) - if manual, ok := atm["manual"].(bool); ok && manual { - manualCount++ - } - } - suite.Equal(1, manualCount, "should have exactly one manual retry attempt") - - // Test retry on disabled destination - disableTests := []APITest{ - { - Name: "PUT /:tenantID/destinations/:destinationID/disable", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID + "/disable", - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "POST /retry - disabled destination", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/retry", - Body: map[string]interface{}{ - "event_id": eventID, - "destination_id": destinationID, - }, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusBadRequest, - Body: map[string]interface{}{ - "message": "Destination is disabled", - }, - }, - }, - }, - } - suite.RunAPITests(suite.T(), disableTests) - - // Cleanup - cleanupTests := []APITest{ - { - Name: "DELETE mockserver/destinations/:destinationID", - Request: httpclient.Request{ - Method: httpclient.MethodDELETE, - BaseURL: suite.mockServerBaseURL, - Path: "/destinations/" + destinationID, - }, - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - { - Name: "DELETE /:tenantID", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodDELETE, - Path: "/tenants/" + tenantID, - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusOK, - }, - }, - }, - } - suite.RunAPITests(suite.T(), cleanupTests) -} diff --git a/cmd/e2e/regressions_test.go b/cmd/e2e/regressions_test.go new file mode 100644 index 00000000..ceb55095 --- /dev/null +++ b/cmd/e2e/regressions_test.go @@ -0,0 +1,306 @@ +package e2e_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/hookdeck/outpost/cmd/e2e/configs" + "github.com/hookdeck/outpost/internal/app" + "github.com/hookdeck/outpost/internal/config" + "github.com/hookdeck/outpost/internal/util/testinfra" + "github.com/stretchr/testify/require" +) + +// regressionHTTPClient is a simple HTTP helper for standalone regression tests. +type regressionHTTPClient struct { + client *http.Client + apiKey string +} + +func newRegressionHTTPClient(apiKey string) *regressionHTTPClient { + return ®ressionHTTPClient{ + client: &http.Client{Timeout: 10 * time.Second}, + apiKey: apiKey, + } +} + +func (c *regressionHTTPClient) doJSON(t *testing.T, method, url string, body any, result any) int { + t.Helper() + return c.doJSONWithAuth(t, method, url, "Bearer "+c.apiKey, body, result) +} + +func (c *regressionHTTPClient) doJSONRaw(t *testing.T, method, url string, body any, result any) int { + t.Helper() + return c.doJSONWithAuth(t, method, url, "", body, result) +} + +func (c *regressionHTTPClient) doJSONWithAuth(t *testing.T, method, url string, authHeader string, body any, result any) int { + t.Helper() + + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + require.NoError(t, err) + bodyReader = bytes.NewReader(b) + } + + req, err := http.NewRequest(method, url, bodyReader) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + if authHeader != "" { + req.Header.Set("Authorization", authHeader) + } + + resp, err := c.client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + if result != nil { + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + if len(respBody) > 0 { + require.NoError(t, json.Unmarshal(respBody, result)) + } + } + + return resp.StatusCode +} + +// TestE2E_Regression_AutoDisableWithoutCallbackURL tests issue #596: +// ALERT_AUTO_DISABLE_DESTINATION=true without ALERT_CALLBACK_URL set. +func TestE2E_Regression_AutoDisableWithoutCallbackURL(t *testing.T) { + t.Parallel() + if testing.Short() { + t.Skip("skipping e2e test") + } + + testinfraCleanup := testinfra.Start(t) + defer testinfraCleanup() + gin.SetMode(gin.TestMode) + mockServerBaseURL := testinfra.GetMockServer(t) + + cfg := configs.Basic(t, configs.BasicOpts{ + LogStorage: configs.LogStorageTypePostgres, + }) + cfg.Alert.CallbackURL = "" + cfg.Alert.AutoDisableDestination = true + cfg.Alert.ConsecutiveFailureCount = 20 + + require.NoError(t, cfg.Validate(config.Flags{})) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + appDone := make(chan struct{}) + go func() { + defer close(appDone) + application := app.New(&cfg) + if err := application.Run(ctx); err != nil { + log.Println("Application stopped:", err) + } + }() + defer func() { + cancel() + <-appDone + }() + + waitForHealthy(t, cfg.APIPort, 5*time.Second) + + client := newRegressionHTTPClient(cfg.APIKey) + apiURL := fmt.Sprintf("http://localhost:%d/api/v1", cfg.APIPort) + + tenantID := fmt.Sprintf("tenant_%d", time.Now().UnixNano()) + destinationID := fmt.Sprintf("dest_%d", time.Now().UnixNano()) + secret := testSecret + + // Create tenant + status := client.doJSON(t, http.MethodPut, apiURL+"/tenants/"+tenantID, nil, nil) + require.Equal(t, 201, status, "failed to create tenant") + + // Configure mock server destination to return errors + status = client.doJSONRaw(t, http.MethodPut, mockServerBaseURL+"/destinations", map[string]any{ + "id": destinationID, + "type": "webhook", + "config": map[string]any{ + "url": fmt.Sprintf("%s/webhook/%s", mockServerBaseURL, destinationID), + }, + "credentials": map[string]any{ + "secret": secret, + }, + }, nil) + require.Equal(t, 200, status, "failed to configure mock server") + + // Create destination + status = client.doJSON(t, http.MethodPost, apiURL+"/tenants/"+tenantID+"/destinations", map[string]any{ + "id": destinationID, + "type": "webhook", + "topics": "*", + "config": map[string]any{ + "url": fmt.Sprintf("%s/webhook/%s", mockServerBaseURL, destinationID), + }, + "credentials": map[string]any{ + "secret": secret, + }, + }, nil) + require.Equal(t, 201, status, "failed to create destination") + + // Publish 21 events that will fail + for i := 0; i < 21; i++ { + status = client.doJSON(t, http.MethodPost, apiURL+"/publish", map[string]any{ + "tenant_id": tenantID, + "topic": "user.created", + "eligible_for_retry": false, + "metadata": map[string]any{ + "should_err": "true", + }, + "data": map[string]any{ + "index": i, + }, + }, nil) + require.Equal(t, 202, status, "failed to publish event %d", i) + } + + // Poll until destination is disabled (replaces flaky time.Sleep) + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + var dest map[string]any + status = client.doJSON(t, http.MethodGet, apiURL+"/tenants/"+tenantID+"/destinations/"+destinationID, nil, &dest) + require.Equal(t, 200, status, "failed to get destination") + if dest["disabled_at"] != nil { + return // success + } + time.Sleep(100 * time.Millisecond) + } + t.Fatal("timed out waiting for destination to be disabled (disabled_at should not be null) - issue #596") +} + +// TestE2E_Regression_RetryRaceCondition verifies that retries are not lost when +// the retry scheduler queries logstore before the event has been persisted. +func TestE2E_Regression_RetryRaceCondition(t *testing.T) { + t.Parallel() + if testing.Short() { + t.Skip("skipping e2e test") + } + + testinfraCleanup := testinfra.Start(t) + defer testinfraCleanup() + gin.SetMode(gin.TestMode) + mockServerBaseURL := testinfra.GetMockServer(t) + + cfg := configs.Basic(t, configs.BasicOpts{ + LogStorage: configs.LogStorageTypeClickHouse, + }) + + // SLOW log persistence: batch won't flush for 5 seconds + cfg.LogBatchThresholdSeconds = 5 + cfg.LogBatchSize = 10000 + + // FAST retry: retry fires after ~1 second + cfg.RetryIntervalSeconds = 1 + cfg.RetryPollBackoffMs = 50 + cfg.RetryMaxLimit = 5 + cfg.RetryVisibilityTimeoutSeconds = 2 + + require.NoError(t, cfg.Validate(config.Flags{})) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + appDone := make(chan struct{}) + go func() { + defer close(appDone) + application := app.New(&cfg) + if err := application.Run(ctx); err != nil { + log.Println("Application stopped:", err) + } + }() + defer func() { + cancel() + <-appDone + }() + + waitForHealthy(t, cfg.APIPort, 5*time.Second) + + client := newRegressionHTTPClient(cfg.APIKey) + apiURL := fmt.Sprintf("http://localhost:%d/api/v1", cfg.APIPort) + + tenantID := fmt.Sprintf("tenant_race_%d", time.Now().UnixNano()) + destinationID := fmt.Sprintf("dest_race_%d", time.Now().UnixNano()) + secret := testSecret + + // Create tenant + status := client.doJSON(t, http.MethodPut, apiURL+"/tenants/"+tenantID, nil, nil) + require.Equal(t, 201, status, "failed to create tenant") + + // Configure mock server destination + status = client.doJSONRaw(t, http.MethodPut, mockServerBaseURL+"/destinations", map[string]any{ + "id": destinationID, + "type": "webhook", + "config": map[string]any{ + "url": fmt.Sprintf("%s/webhook/%s", mockServerBaseURL, destinationID), + }, + "credentials": map[string]any{ + "secret": secret, + }, + }, nil) + require.Equal(t, 200, status, "failed to configure mock server") + + // Create destination + status = client.doJSON(t, http.MethodPost, apiURL+"/tenants/"+tenantID+"/destinations", map[string]any{ + "id": destinationID, + "type": "webhook", + "topics": "*", + "config": map[string]any{ + "url": fmt.Sprintf("%s/webhook/%s", mockServerBaseURL, destinationID), + }, + "credentials": map[string]any{ + "secret": secret, + }, + }, nil) + require.Equal(t, 201, status, "failed to create destination") + + // Publish event that will always fail (should_err: true) + status = client.doJSON(t, http.MethodPost, apiURL+"/publish", map[string]any{ + "tenant_id": tenantID, + "topic": "user.created", + "eligible_for_retry": true, + "metadata": map[string]any{ + "should_err": "true", + }, + "data": map[string]any{ + "test": "race-condition-test", + }, + }, nil) + require.Equal(t, 202, status, "failed to publish event") + + // Poll for at least 2 delivery attempts (initial + retry after event persisted) + deadline := time.Now().Add(15 * time.Second) + var eventCount int + for time.Now().Before(deadline) { + resp, err := http.Get(mockServerBaseURL + "/destinations/" + destinationID + "/events") + if err == nil { + if resp.StatusCode == http.StatusOK { + body, _ := io.ReadAll(resp.Body) + var events []any + if json.Unmarshal(body, &events) == nil { + eventCount = len(events) + } + } + resp.Body.Close() + if eventCount >= 2 { + break + } + } + time.Sleep(500 * time.Millisecond) + } + require.GreaterOrEqual(t, eventCount, 2, + "expected multiple delivery attempts (initial + retry after event persisted)") +} diff --git a/cmd/e2e/retry_test.go b/cmd/e2e/retry_test.go new file mode 100644 index 00000000..3b6c460f --- /dev/null +++ b/cmd/e2e/retry_test.go @@ -0,0 +1,119 @@ +package e2e_test + +import ( + "net/http" + + "github.com/hookdeck/outpost/internal/idgen" +) + +func (s *basicSuite) TestRetry_FailedDeliveryAutoRetries() { + tenant := s.createTenant() + secret := testSecret + dest := s.createWebhookDestination(tenant.ID, "*", withSecret(secret)) + + s.publish(tenant.ID, "user.created", map[string]any{ + "test": "auto_retry", + }, + withRetry(), + withPublishMetadata(map[string]string{"should_err": "true"}), + ) + + // Wait for at least 2 delivery attempts (initial + retry) + s.waitForNewMockServerEvents(dest.mockID, 2) + + // Wait for attempts to be logged + attempts := s.waitForNewAttempts(tenant.ID, 2) + s.Require().GreaterOrEqual(len(attempts), 2, "should have at least 2 attempts from automated retry") + + // Fetch in asc order and verify attempt_number increments + var resp struct { + Models []map[string]any `json:"models"` + } + status := s.doJSON(http.MethodGet, s.apiURL("/attempts?tenant_id="+tenant.ID+"&dir=asc"), nil, &resp) + s.Require().Equal(http.StatusOK, status) + s.Require().GreaterOrEqual(len(resp.Models), 2) + + for i, atm := range resp.Models { + s.Equal(float64(i), atm["attempt_number"], + "attempt %d should have attempt_number=%d (automated retry increments)", i, i) + } +} + +func (s *basicSuite) TestRetry_ManualRetryCreatesNewAttempt() { + tenant := s.createTenant() + dest := s.createWebhookDestination(tenant.ID, "*", withSecret(testSecret), withResponseStatus(500)) + + eventID := idgen.Event() + s.publish(tenant.ID, "user.created", map[string]any{ + "user_id": "456", + }, withEventID(eventID)) + + // Wait for initial attempt to fail + s.waitForNewAttempts(tenant.ID, 1) + + // Verify first attempt has attempt_number=0 + var attResp struct { + Models []map[string]any `json:"models"` + } + status := s.doJSON(http.MethodGet, s.apiURL("/attempts?tenant_id="+tenant.ID+"&event_id="+eventID), nil, &attResp) + s.Require().Equal(http.StatusOK, status) + s.Require().NotEmpty(attResp.Models) + s.Equal(float64(0), attResp.Models[0]["attempt_number"]) + + // Reconfigure mock to succeed + dest.SetResponse(s, 200) + + // Manual retry + retryStatus := s.retryEvent(eventID, dest.ID) + s.Equal(http.StatusAccepted, retryStatus) + + // Wait for retry attempt + s.waitForNewAttempts(tenant.ID, 2) + + // Verify: 2 attempts, one manual=true + var verifyResp struct { + Models []map[string]any `json:"models"` + } + status = s.doJSON(http.MethodGet, s.apiURL("/attempts?tenant_id="+tenant.ID+"&event_id="+eventID+"&dir=asc"), nil, &verifyResp) + s.Require().Equal(http.StatusOK, status) + s.Require().Len(verifyResp.Models, 2) + + // Both should have attempt_number=0 (manual retry resets) + for _, atm := range verifyResp.Models { + s.Equal(float64(0), atm["attempt_number"]) + } + + // Verify one manual=true + manualCount := 0 + for _, atm := range verifyResp.Models { + if manual, ok := atm["manual"].(bool); ok && manual { + manualCount++ + } + } + s.Equal(1, manualCount, "should have exactly one manual retry attempt") +} + +func (s *basicSuite) TestRetry_ManualRetryNonExistentEvent() { + status := s.retryEvent(idgen.Event(), idgen.Destination()) + s.Equal(http.StatusNotFound, status) +} + +func (s *basicSuite) TestRetry_ManualRetryOnDisabledDestinationRejected() { + tenant := s.createTenant() + dest := s.createWebhookDestination(tenant.ID, "*") + + eventID := idgen.Event() + s.publish(tenant.ID, "user.created", map[string]any{ + "test": "disabled_retry", + }, withEventID(eventID)) + + // Wait for delivery + s.waitForNewAttempts(tenant.ID, 1) + + // Disable destination + s.disableDestination(tenant.ID, dest.ID) + + // Retry should be rejected + status := s.retryEvent(eventID, dest.ID) + s.Equal(http.StatusBadRequest, status) +} diff --git a/cmd/e2e/signatures_test.go b/cmd/e2e/signatures_test.go new file mode 100644 index 00000000..51e0d1ed --- /dev/null +++ b/cmd/e2e/signatures_test.go @@ -0,0 +1,244 @@ +package e2e_test + +import ( + "net/http" + "time" +) + +func (s *basicSuite) TestWebhookSignatures_RotatedSecretAcceptedDuringGracePeriod() { + tenant := s.createTenant() + secret := testSecret + newSecret := testSecretAlt + dest := s.createWebhookDestination(tenant.ID, "*", withSecret(secret)) + + // Rotate secret on mock server: mock now verifies with new secret + previous secret + dest.SetCredentials(s, map[string]string{ + "secret": newSecret, + "previous_secret": secret, + "previous_secret_invalid_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339), + }) + + // Publish — outpost still signs with original secret, mock's previous_secret should match + s.publish(tenant.ID, "user.created", map[string]any{ + "event_id": "rotated_test", + }) + + events := s.waitForNewMockServerEvents(dest.mockID, 1) + s.Require().Len(events, 1) + s.True(events[0].Verified, "signature should be verified via previous_secret during grace period") +} + +func (s *basicSuite) TestWebhookSignatures_WrongSecretFailsVerification() { + tenant := s.createTenant() + secret := testSecret + dest := s.createWebhookDestination(tenant.ID, "*", withSecret(secret)) + + // Set wrong secret on mock server + dest.SetSecret(s, "wrong-secret") + + s.publish(tenant.ID, "user.created", map[string]any{ + "event_id": "wrong_secret_test", + }) + + events := s.waitForNewMockServerEvents(dest.mockID, 1) + s.Require().Len(events, 1) + s.True(events[0].Success, "delivery should still succeed") + s.False(events[0].Verified, "signature should NOT be verified with wrong secret") +} + +func (s *basicSuite) TestWebhookSignatures_SecretAutoGeneratedOnCreate() { + tenant := s.createTenant() + dest := s.createWebhookDestination(tenant.ID, "*") // No secret specified + + got := s.getDestination(tenant.ID, dest.ID) + s.Require().NotEmpty(got.Credentials["secret"], "secret should be auto-generated") + s.Require().GreaterOrEqual(len(got.Credentials["secret"]), 32, "auto-generated secret should be at least 32 chars") +} + +func (s *basicSuite) TestWebhookSignatures_SecretRotationViaAPI() { + tenant := s.createTenant() + dest := s.createWebhookDestination(tenant.ID, "*") // Auto-generated secret + + initial := s.getDestination(tenant.ID, dest.ID) + initialSecret := initial.Credentials["secret"] + s.Require().NotEmpty(initialSecret) + s.Require().Empty(initial.Credentials["previous_secret"]) + s.Require().Empty(initial.Credentials["previous_secret_invalid_at"]) + + // Rotate secret + s.updateDestination(tenant.ID, dest.ID, map[string]any{ + "credentials": map[string]any{ + "rotate_secret": true, + }, + }) + + // Verify rotation + rotated := s.getDestination(tenant.ID, dest.ID) + s.NotEmpty(rotated.Credentials["secret"]) + s.NotEqual(initialSecret, rotated.Credentials["secret"], "secret should have changed") + s.Equal(initialSecret, rotated.Credentials["previous_secret"], "previous_secret should be the old secret") + s.NotEmpty(rotated.Credentials["previous_secret_invalid_at"], "previous_secret_invalid_at should be set") +} + +func (s *basicSuite) TestWebhookSignatures_TenantCannotSetCustomSecret() { + tenant := s.createTenant() + token := s.jwtFor(tenant.ID) + destID := "dest_tenant_secret_test" + + // Attempt to create destination with secret via JWT → 422 + var errResp map[string]any + status := s.doJSONWithToken(http.MethodPost, s.apiURL("/tenants/"+tenant.ID+"/destinations"), token, map[string]any{ + "id": destID, + "type": "webhook", + "topics": "*", + "config": map[string]any{ + "url": s.mockServerURL() + "/webhook/" + destID, + }, + "credentials": map[string]any{ + "secret": "any-secret", + }, + }, &errResp) + s.Equal(http.StatusUnprocessableEntity, status) + s.Equal("validation error", errResp["message"]) + + // Create destination without secret via JWT → 201 + var createResp map[string]any + status = s.doJSONWithToken(http.MethodPost, s.apiURL("/tenants/"+tenant.ID+"/destinations"), token, map[string]any{ + "id": destID, + "type": "webhook", + "topics": "*", + "config": map[string]any{ + "url": s.mockServerURL() + "/webhook/" + destID, + }, + }, &createResp) + s.Require().Equal(http.StatusCreated, status) + + // PATCH with secret via JWT → 422 + status = s.doJSONWithToken(http.MethodPatch, s.apiURL("/tenants/"+tenant.ID+"/destinations/"+destID), token, map[string]any{ + "credentials": map[string]any{ + "secret": "new-secret", + }, + }, &errResp) + s.Equal(http.StatusUnprocessableEntity, status) + + // PATCH with previous_secret via JWT → 422 + status = s.doJSONWithToken(http.MethodPatch, s.apiURL("/tenants/"+tenant.ID+"/destinations/"+destID), token, map[string]any{ + "credentials": map[string]any{ + "previous_secret": "another-secret", + }, + }, &errResp) + s.Equal(http.StatusUnprocessableEntity, status) + + // PATCH with previous_secret_invalid_at via JWT → 422 + status = s.doJSONWithToken(http.MethodPatch, s.apiURL("/tenants/"+tenant.ID+"/destinations/"+destID), token, map[string]any{ + "credentials": map[string]any{ + "previous_secret_invalid_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339), + }, + }, &errResp) + s.Equal(http.StatusUnprocessableEntity, status) + + // PATCH with rotate_secret:true via JWT → 200 (allowed) + var rotateResp map[string]any + status = s.doJSONWithToken(http.MethodPatch, s.apiURL("/tenants/"+tenant.ID+"/destinations/"+destID), token, map[string]any{ + "credentials": map[string]any{ + "rotate_secret": true, + }, + }, &rotateResp) + s.Equal(http.StatusOK, status) +} + +func (s *basicSuite) TestWebhookSignatures_AdminCanSetCustomSecret() { + tenant := s.createTenant() + secret := testSecret + newSecret := testSecretAlt + + // Create destination with explicit secret + dest := s.createWebhookDestination(tenant.ID, "*", withSecret(secret)) + + // Verify custom secret was set + got := s.getDestination(tenant.ID, dest.ID) + s.Equal(secret, got.Credentials["secret"]) + + // PATCH with new secret directly + s.updateDestination(tenant.ID, dest.ID, map[string]any{ + "credentials": map[string]any{ + "secret": newSecret, + }, + }) + got = s.getDestination(tenant.ID, dest.ID) + s.Equal(newSecret, got.Credentials["secret"]) + + // Set previous_secret + previous_secret_invalid_at + gracePeriod := time.Now().Add(24 * time.Hour).Format(time.RFC3339) + s.updateDestination(tenant.ID, dest.ID, map[string]any{ + "credentials": map[string]any{ + "secret": newSecret, + "previous_secret": secret, + "previous_secret_invalid_at": gracePeriod, + }, + }) + got = s.getDestination(tenant.ID, dest.ID) + s.Equal(secret, got.Credentials["previous_secret"]) + s.NotEmpty(got.Credentials["previous_secret_invalid_at"]) + + // Validation: empty secret → 422 + var errResp map[string]any + status := s.doJSON(http.MethodPatch, s.apiURL("/tenants/"+tenant.ID+"/destinations/"+dest.ID), map[string]any{ + "credentials": map[string]any{ + "secret": "", + "previous_secret": secret, + "previous_secret_invalid_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339), + }, + }, &errResp) + s.Equal(http.StatusUnprocessableEntity, status) + + // Validation: invalid date format → 422 + status = s.doJSON(http.MethodPatch, s.apiURL("/tenants/"+tenant.ID+"/destinations/"+dest.ID), map[string]any{ + "credentials": map[string]any{ + "secret": newSecret, + "previous_secret": secret, + "previous_secret_invalid_at": "invalid-date", + }, + }, &errResp) + s.Equal(http.StatusUnprocessableEntity, status) + + // Validation: rotate_secret on create → 422 + status = s.doJSON(http.MethodPost, s.apiURL("/tenants/"+tenant.ID+"/destinations"), map[string]any{ + "id": dest.ID + "-rotate", + "type": "webhook", + "topics": "*", + "config": map[string]any{ + "url": s.mockServerURL() + "/webhook/" + dest.ID, + }, + "credentials": map[string]any{ + "rotate_secret": true, + }, + }, &errResp) + s.Equal(http.StatusUnprocessableEntity, status) + + // Rotate secret as admin + s.updateDestination(tenant.ID, dest.ID, map[string]any{ + "credentials": map[string]any{ + "rotate_secret": true, + }, + }) + + // Verify rotation worked + got = s.getDestination(tenant.ID, dest.ID) + s.NotEqual(newSecret, got.Credentials["secret"], "secret should have changed after rotation") + s.NotEmpty(got.Credentials["previous_secret"]) + s.NotEmpty(got.Credentials["previous_secret_invalid_at"]) + + // Admin unset previous_secret + s.updateDestination(tenant.ID, dest.ID, map[string]any{ + "credentials": map[string]any{ + "previous_secret": "", + "previous_secret_invalid_at": "", + }, + }) + + got = s.getDestination(tenant.ID, dest.ID) + s.NotEmpty(got.Credentials["secret"], "secret should still exist") + s.Empty(got.Credentials["previous_secret"], "previous_secret should be cleared") + s.Empty(got.Credentials["previous_secret_invalid_at"], "previous_secret_invalid_at should be cleared") +} diff --git a/cmd/e2e/suites_test.go b/cmd/e2e/suites_test.go index f2531e19..fce36520 100644 --- a/cmd/e2e/suites_test.go +++ b/cmd/e2e/suites_test.go @@ -2,7 +2,6 @@ package e2e_test import ( "context" - "encoding/json" "fmt" "log" "net/http" @@ -12,13 +11,11 @@ import ( "github.com/gin-gonic/gin" "github.com/hookdeck/outpost/cmd/e2e/alert" "github.com/hookdeck/outpost/cmd/e2e/configs" - "github.com/hookdeck/outpost/cmd/e2e/httpclient" "github.com/hookdeck/outpost/internal/app" "github.com/hookdeck/outpost/internal/config" "github.com/hookdeck/outpost/internal/redis" "github.com/hookdeck/outpost/internal/util/testinfra" "github.com/hookdeck/outpost/internal/util/testutil" - "github.com/santhosh-tekuri/jsonschema/v6" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -41,85 +38,6 @@ func waitForHealthy(t *testing.T, port int, timeout time.Duration) { t.Fatalf("timed out waiting for health check at %s", healthURL) } -// waitForAttempts polls until at least minCount attempts exist for the given path. -func (s *e2eSuite) waitForAttempts(t *testing.T, path string, minCount int, timeout time.Duration) { - t.Helper() - deadline := time.Now().Add(timeout) - var lastCount int - var lastErr error - var lastStatus int - for time.Now().Before(deadline) { - resp, err := s.client.Do(s.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: path, - })) - if err != nil { - lastErr = err - time.Sleep(100 * time.Millisecond) - continue - } - lastStatus = resp.StatusCode - if resp.StatusCode == http.StatusOK { - if body, ok := resp.Body.(map[string]interface{}); ok { - if models, ok := body["models"].([]interface{}); ok { - lastCount = len(models) - if lastCount >= minCount { - return - } - } - } - } - time.Sleep(100 * time.Millisecond) - } - if lastErr != nil { - t.Fatalf("timed out waiting for %d attempts at %s: last error: %v", minCount, path, lastErr) - } - t.Fatalf("timed out waiting for %d attempts at %s: got %d (status %d)", minCount, path, lastCount, lastStatus) -} - -// waitForDestinationDisabled polls until the destination has disabled_at set (non-null). -func (s *e2eSuite) waitForDestinationDisabled(t *testing.T, tenantID, destinationID string, timeout time.Duration) { - t.Helper() - path := "/tenants/" + tenantID + "/destinations/" + destinationID - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - resp, err := s.client.Do(s.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: path, - })) - if err == nil && resp.StatusCode == http.StatusOK { - if body, ok := resp.Body.(map[string]interface{}); ok { - if disabledAt, exists := body["disabled_at"]; exists && disabledAt != nil { - return - } - } - } - time.Sleep(100 * time.Millisecond) - } - t.Fatalf("timed out waiting for destination %s to be disabled", destinationID) -} - -// waitForMockServerEvents polls the mock server until at least minCount events exist for the destination. -func (s *e2eSuite) waitForMockServerEvents(t *testing.T, destinationID string, minCount int, timeout time.Duration) { - t.Helper() - path := "/destinations/" + destinationID + "/events" - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - resp, err := s.client.Do(httpclient.Request{ - Method: httpclient.MethodGET, - BaseURL: s.mockServerBaseURL, - Path: path, - }) - if err == nil && resp.StatusCode == http.StatusOK { - if events, ok := resp.Body.([]interface{}); ok && len(events) >= minCount { - return - } - } - time.Sleep(100 * time.Millisecond) - } - t.Fatalf("timed out waiting for %d events at mock server %s", minCount, path) -} - type e2eSuite struct { ctx context.Context cancel context.CancelFunc @@ -127,7 +45,6 @@ type e2eSuite struct { mockServerBaseURL string mockServerInfra *testinfra.MockServerInfra cleanup func() - client httpclient.Client appDone chan struct{} } @@ -136,7 +53,6 @@ func (suite *e2eSuite) SetupSuite() { suite.ctx = ctx suite.cancel = cancel suite.appDone = make(chan struct{}) - suite.client = httpclient.New(fmt.Sprintf("http://localhost:%d/api/v1", suite.config.APIPort), suite.config.APIKey) go func() { defer close(suite.appDone) application := app.New(&suite.config) @@ -155,100 +71,6 @@ func (s *e2eSuite) TearDownSuite() { s.cleanup() } -func (s *e2eSuite) AuthRequest(req httpclient.Request) httpclient.Request { - if req.Headers == nil { - req.Headers = map[string]string{} - } - req.Headers["Authorization"] = fmt.Sprintf("Bearer %s", s.config.APIKey) - return req -} - -func (s *e2eSuite) AuthJWTRequest(req httpclient.Request, token string) httpclient.Request { - if req.Headers == nil { - req.Headers = map[string]string{} - } - req.Headers["Authorization"] = fmt.Sprintf("Bearer %s", token) - return req -} - -func (suite *e2eSuite) RunAPITests(t *testing.T, tests []APITest) { - t.Helper() - for _, test := range tests { - t.Run(test.Name, func(t *testing.T) { - test.Run(t, suite.client) - }) - } -} - -// MockServerPoll configures polling for the mock server before running the test. -type MockServerPoll struct { - BaseURL string // Mock server base URL - DestID string // Destination ID to poll - MinCount int // Minimum events to wait for - Timeout time.Duration // Poll timeout -} - -type APITest struct { - Name string - Delay time.Duration // Deprecated: use WaitForMockEvents instead - WaitFor *MockServerPoll // Poll mock server before running test - Request httpclient.Request - Expected APITestExpectation -} - -type APITestExpectation struct { - Match *httpclient.Response - Validate map[string]interface{} -} - -func (test *APITest) Run(t *testing.T, client httpclient.Client) { - t.Helper() - - // Poll mock server if configured (preferred over Delay) - if test.WaitFor != nil { - w := test.WaitFor - path := "/destinations/" + w.DestID + "/events" - deadline := time.Now().Add(w.Timeout) - for time.Now().Before(deadline) { - resp, err := client.Do(httpclient.Request{ - Method: httpclient.MethodGET, - BaseURL: w.BaseURL, - Path: path, - }) - if err == nil && resp.StatusCode == http.StatusOK { - if events, ok := resp.Body.([]interface{}); ok && len(events) >= w.MinCount { - break - } - } - time.Sleep(100 * time.Millisecond) - } - } else if test.Delay > 0 { - time.Sleep(test.Delay) - } - - resp, err := client.Do(test.Request) - require.NoError(t, err) - - if test.Expected.Match != nil { - require.Equal(t, test.Expected.Match.StatusCode, resp.StatusCode) - if test.Expected.Match.Body != nil { - require.True(t, resp.MatchBody(test.Expected.Match.Body), "expected body %s, got %s", test.Expected.Match.Body, resp.Body) - } - } - - if test.Expected.Validate != nil { - c := jsonschema.NewCompiler() - require.NoError(t, c.AddResource("schema.json", test.Expected.Validate)) - schema, err := c.Compile("schema.json") - require.NoError(t, err, "failed to compile schema: %v", err) - respStr, _ := json.Marshal(resp) - var respJSON map[string]interface{} - require.NoError(t, json.Unmarshal(respStr, &respJSON), "failed to parse response: %v", err) - validationErr := schema.Validate(respJSON) - require.NoError(t, validationErr, "response validation failed: %v: %s", validationErr, respJSON) - } -} - type basicSuite struct { suite.Suite e2eSuite @@ -257,19 +79,7 @@ type basicSuite struct { deploymentID string // Optional deployment ID hasRediSearch bool // Whether the Redis backend supports RediSearch (only RedisStack) alertServer *alert.AlertMockServer - failed bool // Fail-fast: skip remaining tests after first failure -} - -func (s *basicSuite) BeforeTest(suiteName, testName string) { - if s.failed { - s.T().Skip("skipping due to previous test failure") - } -} - -func (s *basicSuite) AfterTest(suiteName, testName string) { - if s.T().Failed() { - s.failed = true - } + httpClient *http.Client // Used by doJSON helpers } func (suite *basicSuite) SetupSuite() { @@ -306,10 +116,16 @@ func (suite *basicSuite) SetupSuite() { } suite.e2eSuite.SetupSuite() + suite.httpClient = &http.Client{Timeout: 10 * time.Second} + // wait for outpost services to start waitForHealthy(t, cfg.APIPort, 5*time.Second) } +func (s *basicSuite) SetupTest() { + s.alertServer.Reset() +} + func (s *basicSuite) TearDownSuite() { s.e2eSuite.TearDownSuite() } @@ -393,317 +209,3 @@ func TestE2E_Compat_RedisCluster(t *testing.T) { redisConfig: redisConfig, }) } - -// ============================================================================= -// Regression Tests -// ============================================================================= -// Standalone tests for specific issues/scenarios. - -// TestE2E_Regression_AutoDisableWithoutCallbackURL tests issue #596: -// ALERT_AUTO_DISABLE_DESTINATION=true without ALERT_CALLBACK_URL set. -func TestE2E_Regression_AutoDisableWithoutCallbackURL(t *testing.T) { - t.Parallel() - if testing.Short() { - t.Skip("skipping e2e test") - } - - // Setup infrastructure - testinfraCleanup := testinfra.Start(t) - defer testinfraCleanup() - gin.SetMode(gin.TestMode) - mockServerBaseURL := testinfra.GetMockServer(t) - - // Configure WITHOUT alert callback URL (the issue #596 scenario) - cfg := configs.Basic(t, configs.BasicOpts{ - LogStorage: configs.LogStorageTypePostgres, - }) - cfg.Alert.CallbackURL = "" // No callback URL - cfg.Alert.AutoDisableDestination = true // Auto-disable enabled - cfg.Alert.ConsecutiveFailureCount = 20 // Default threshold - - require.NoError(t, cfg.Validate(config.Flags{})) - - // Start application - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - appDone := make(chan struct{}) - go func() { - defer close(appDone) - application := app.New(&cfg) - if err := application.Run(ctx); err != nil { - log.Println("Application stopped:", err) - } - }() - defer func() { - cancel() - <-appDone - }() - - // Wait for services to start - waitForHealthy(t, cfg.APIPort, 5*time.Second) - - // Setup test client - client := httpclient.New(fmt.Sprintf("http://localhost:%d/api/v1", cfg.APIPort), cfg.APIKey) - mockServerInfra := testinfra.NewMockServerInfra(mockServerBaseURL) - - // Test data - tenantID := fmt.Sprintf("tenant_%d", time.Now().UnixNano()) - destinationID := fmt.Sprintf("dest_%d", time.Now().UnixNano()) - secret := "testsecret1234567890abcdefghijklmnop" - - // Create tenant - resp, err := client.Do(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - Headers: map[string]string{"Authorization": "Bearer " + cfg.APIKey}, - }) - require.NoError(t, err) - require.Equal(t, 201, resp.StatusCode, "failed to create tenant") - - // Configure mock server destination to return errors - resp, err = client.Do(httpclient.Request{ - Method: httpclient.MethodPUT, - BaseURL: mockServerBaseURL, - Path: "/destinations", - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", mockServerBaseURL, destinationID), - }, - "credentials": map[string]interface{}{ - "secret": secret, - }, - }, - }) - require.NoError(t, err) - require.Equal(t, 200, resp.StatusCode, "failed to configure mock server") - - // Create destination - resp, err = client.Do(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Headers: map[string]string{"Authorization": "Bearer " + cfg.APIKey}, - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", mockServerBaseURL, destinationID), - }, - "credentials": map[string]interface{}{ - "secret": secret, - }, - }, - }) - require.NoError(t, err) - require.Equal(t, 201, resp.StatusCode, "failed to create destination") - - // Publish 21 events that will fail (1 more than threshold to test idempotency) - for i := 0; i < 21; i++ { - resp, err = client.Do(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/publish", - Headers: map[string]string{"Authorization": "Bearer " + cfg.APIKey}, - Body: map[string]interface{}{ - "tenant_id": tenantID, - "topic": "user.created", - "eligible_for_retry": false, - "metadata": map[string]any{ - "should_err": "true", - }, - "data": map[string]any{ - "index": i, - }, - }, - }) - require.NoError(t, err) - require.Equal(t, 202, resp.StatusCode, "failed to publish event %d", i) - } - - // Wait for deliveries to be processed - time.Sleep(time.Second) - - // Check if destination is disabled - resp, err = client.Do(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/destinations/" + destinationID, - Headers: map[string]string{"Authorization": "Bearer " + cfg.APIKey}, - }) - require.NoError(t, err) - require.Equal(t, 200, resp.StatusCode, "failed to get destination") - - // Parse response to check disabled_at - bodyMap, ok := resp.Body.(map[string]interface{}) - require.True(t, ok, "response body should be a map") - - disabledAt := bodyMap["disabled_at"] - require.NotNil(t, disabledAt, "destination should be disabled (disabled_at should not be null) - issue #596") - - // Cleanup mock server - _ = mockServerInfra -} - -// TestE2E_Regression_RetryRaceCondition verifies that retries are not lost when -// the retry scheduler queries logstore before the event has been persisted. -// -// Test configuration creates a timing window where retry fires before log persistence: -// - LogBatchThresholdSeconds = 5 (slow persistence) -// - RetryIntervalSeconds = 1 (fast retry) -// - RetryVisibilityTimeoutSeconds = 2 (quick reprocessing when event not found) -// -// Expected behavior: retry remains in queue until event is available, then succeeds. -func TestE2E_Regression_RetryRaceCondition(t *testing.T) { - t.Parallel() - if testing.Short() { - t.Skip("skipping e2e test") - } - - // Setup infrastructure - testinfraCleanup := testinfra.Start(t) - defer testinfraCleanup() - gin.SetMode(gin.TestMode) - mockServerBaseURL := testinfra.GetMockServer(t) - - // Configure with slow log persistence and fast retry - cfg := configs.Basic(t, configs.BasicOpts{ - LogStorage: configs.LogStorageTypeClickHouse, - }) - - // SLOW log persistence: batch won't flush for 5 seconds - cfg.LogBatchThresholdSeconds = 5 - cfg.LogBatchSize = 10000 // High batch size to prevent early flush - - // FAST retry: retry fires after ~1 second - cfg.RetryIntervalSeconds = 1 - cfg.RetryPollBackoffMs = 50 - cfg.RetryMaxLimit = 5 - cfg.RetryVisibilityTimeoutSeconds = 2 // Short VT so retry happens quickly after event not found - - require.NoError(t, cfg.Validate(config.Flags{})) - - // Start application - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - appDone := make(chan struct{}) - go func() { - defer close(appDone) - application := app.New(&cfg) - if err := application.Run(ctx); err != nil { - log.Println("Application stopped:", err) - } - }() - defer func() { - cancel() - <-appDone - }() - - // Wait for services to start - waitForHealthy(t, cfg.APIPort, 5*time.Second) - - // Setup test client - client := httpclient.New(fmt.Sprintf("http://localhost:%d/api/v1", cfg.APIPort), cfg.APIKey) - mockServerInfra := testinfra.NewMockServerInfra(mockServerBaseURL) - - // Test data - tenantID := fmt.Sprintf("tenant_race_%d", time.Now().UnixNano()) - destinationID := fmt.Sprintf("dest_race_%d", time.Now().UnixNano()) - secret := "testsecret1234567890abcdefghijklmnop" - - // Create tenant - resp, err := client.Do(httpclient.Request{ - Method: httpclient.MethodPUT, - Path: "/tenants/" + tenantID, - Headers: map[string]string{"Authorization": "Bearer " + cfg.APIKey}, - }) - require.NoError(t, err) - require.Equal(t, 201, resp.StatusCode, "failed to create tenant") - - // Configure mock server destination - resp, err = client.Do(httpclient.Request{ - Method: httpclient.MethodPUT, - BaseURL: mockServerBaseURL, - Path: "/destinations", - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", mockServerBaseURL, destinationID), - }, - "credentials": map[string]interface{}{ - "secret": secret, - }, - }, - }) - require.NoError(t, err) - require.Equal(t, 200, resp.StatusCode, "failed to configure mock server") - - // Create destination - resp, err = client.Do(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/destinations", - Headers: map[string]string{"Authorization": "Bearer " + cfg.APIKey}, - Body: map[string]interface{}{ - "id": destinationID, - "type": "webhook", - "topics": "*", - "config": map[string]interface{}{ - "url": fmt.Sprintf("%s/webhook/%s", mockServerBaseURL, destinationID), - }, - "credentials": map[string]interface{}{ - "secret": secret, - }, - }, - }) - require.NoError(t, err) - require.Equal(t, 201, resp.StatusCode, "failed to create destination") - - // Publish event that will always fail (should_err: true) - // We want to verify that retries happen (mock server is hit multiple times) - resp, err = client.Do(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/publish", - Headers: map[string]string{"Authorization": "Bearer " + cfg.APIKey}, - Body: map[string]interface{}{ - "tenant_id": tenantID, - "topic": "user.created", - "eligible_for_retry": true, - "metadata": map[string]interface{}{ - "should_err": "true", // All deliveries fail - }, - "data": map[string]interface{}{ - "test": "race-condition-test", - }, - }, - }) - require.NoError(t, err) - require.Equal(t, 202, resp.StatusCode, "failed to publish event") - - // Wait for retries to complete - // - t=0: Event published, first delivery fails - // - t=1s: Retry fires, event not in logstore yet, message returns to queue - // - t=3s: Message visible again after 2s VT, retry fires again - // - t=5s: Log batch flushes, event now in logstore - // - t=5s+: Retry finds event, delivery succeeds - time.Sleep(10 * time.Second) - - // Verify mock server received multiple delivery attempts - resp, err = client.Do(httpclient.Request{ - Method: httpclient.MethodGET, - BaseURL: mockServerBaseURL, - Path: "/destinations/" + destinationID + "/events", - }) - require.NoError(t, err) - require.Equal(t, 200, resp.StatusCode) - - events, ok := resp.Body.([]interface{}) - require.True(t, ok, "expected events array") - - // Should have at least 2 attempts: initial failure + successful retry - require.GreaterOrEqual(t, len(events), 2, - "expected multiple delivery attempts (initial + retry after event persisted)") - - _ = mockServerInfra -} diff --git a/go.mod b/go.mod index 776f1625..39b9104b 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,6 @@ require ( github.com/go-redis/redis v6.15.9+incompatible github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-migrate/migrate/v4 v4.18.2 - github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/hookdeck/outpost/sdks/outpost-go v0.4.0 github.com/jackc/pgx/v5 v5.7.6 @@ -41,7 +40,6 @@ require ( github.com/rabbitmq/amqp091-go v1.10.0 github.com/redis/go-redis/extra/redisotel/v9 v9.5.3 github.com/redis/go-redis/v9 v9.6.1 - github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 github.com/spf13/viper v1.19.0 github.com/standard-webhooks/standard-webhooks/libraries v0.0.0-20250711233419-a173a6c0125c github.com/stretchr/testify v1.10.0 diff --git a/go.sum b/go.sum index af9e1f8a..e0017210 100644 --- a/go.sum +++ b/go.sum @@ -810,8 +810,6 @@ github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8= github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0= github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= @@ -1227,8 +1225,6 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs= diff --git a/internal/util/testutil/testutil.go b/internal/util/testutil/testutil.go index a9024240..4719b9c0 100644 --- a/internal/util/testutil/testutil.go +++ b/internal/util/testutil/testutil.go @@ -93,10 +93,10 @@ func RandomString(length int) string { } func RandomPortNumber() int { - return 3500 + mathrand.Intn(100) + return 10000 + mathrand.Intn(50000) } -// Create a random port number between 3500 and 3600 +// RandomPort returns a random port string in the range :10000–:59999. func RandomPort() string { return ":" + strconv.Itoa(RandomPortNumber()) } From a7f4fa64573faa16065bf8afb55a108bd533865c Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sun, 1 Feb 2026 14:37:35 +0700 Subject: [PATCH 28/34] test: topic matching --- cmd/e2e/delivery_pipeline_test.go | 32 +++++++++++++++++++++++++++++++ cmd/e2e/helpers_test.go | 2 +- cmd/e2e/suites_test.go | 8 ++++++-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/cmd/e2e/delivery_pipeline_test.go b/cmd/e2e/delivery_pipeline_test.go index 475527b7..cf036c22 100644 --- a/cmd/e2e/delivery_pipeline_test.go +++ b/cmd/e2e/delivery_pipeline_test.go @@ -4,6 +4,7 @@ import ( "time" "github.com/hookdeck/outpost/internal/idgen" + "github.com/hookdeck/outpost/internal/util/testutil" ) func (s *basicSuite) TestDeliveryPipeline_PublishDeliversToWebhook() { @@ -127,6 +128,37 @@ func (s *basicSuite) TestDeliveryPipeline_DuplicateEventPublishReturnsDuplicate( s.True(resp2.Duplicate, "second publish with same ID should be duplicate") } +func (s *basicSuite) TestDeliveryPipeline_TopicRoutesOnlyToMatchingDestinations() { + topicA := testutil.TestTopics[0] // "user.created" + topicB := testutil.TestTopics[1] // "user.deleted" + + tenant := s.createTenant() + destA := s.createWebhookDestination(tenant.ID, topicA, withSecret(testSecret)) + destB := s.createWebhookDestination(tenant.ID, topicB, withSecret(testSecret)) + + // Publish an event for each topic + s.publish(tenant.ID, topicB, map[string]any{ + "event_id": "topic_b_1", + }) + s.publish(tenant.ID, topicA, map[string]any{ + "event_id": "topic_a_1", + }) + + // Each destination should receive exactly its matching event. + // Since topicA was published after topicB, by the time it arrives + // the pipeline has already routed the topicB event. + eventsA := s.waitForNewMockServerEvents(destA.mockID, 1) + eventsB := s.waitForNewMockServerEvents(destB.mockID, 1) + + s.Require().Len(eventsA, 1) + s.Equal("topic_a_1", eventsA[0].Payload["event_id"], + "%s destination should only receive %s events", topicA, topicA) + + s.Require().Len(eventsB, 1) + s.Equal("topic_b_1", eventsB[0].Payload["event_id"], + "%s destination should only receive %s events", topicB, topicB) +} + func (s *basicSuite) TestDeliveryPipeline_EnableAfterDisableResumesDelivery() { tenant := s.createTenant() dest := s.createWebhookDestination(tenant.ID, "*") diff --git a/cmd/e2e/helpers_test.go b/cmd/e2e/helpers_test.go index 857f3034..a16a0d86 100644 --- a/cmd/e2e/helpers_test.go +++ b/cmd/e2e/helpers_test.go @@ -248,7 +248,7 @@ func (s *basicSuite) createWebhookDestination(tenantID, topic string, opts ...de outpostBody := map[string]any{ "id": destID, "type": "webhook", - "topics": topic, + "topics": []string{topic}, "config": map[string]any{ "url": fmt.Sprintf("%s/webhook/%s", s.mockServerURL(), destID), }, diff --git a/cmd/e2e/suites_test.go b/cmd/e2e/suites_test.go index fce36520..a3e56476 100644 --- a/cmd/e2e/suites_test.go +++ b/cmd/e2e/suites_test.go @@ -65,8 +65,12 @@ func (suite *e2eSuite) SetupSuite() { func (s *e2eSuite) TearDownSuite() { if s.cancel != nil { s.cancel() - // Wait for application to fully shut down before cleaning up resources - <-s.appDone + // Wait for application to shut down, but don't block forever. + select { + case <-s.appDone: + case <-time.After(30 * time.Second): + log.Println("WARNING: application did not shut down within 30s, proceeding with cleanup") + } } s.cleanup() } From c2c198ec6ceee5d4d57f2822830027979ddd918a Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sun, 1 Feb 2026 14:37:50 +0700 Subject: [PATCH 29/34] chore: gofmt --- cmd/e2e/alerts_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/e2e/alerts_test.go b/cmd/e2e/alerts_test.go index efecff78..cbb2cc4e 100644 --- a/cmd/e2e/alerts_test.go +++ b/cmd/e2e/alerts_test.go @@ -81,4 +81,3 @@ func (s *basicSuite) TestAlerts_SuccessResetsConsecutiveFailureCounter() { "alert %d should have %d consecutive failures", i, expectedCounts[i]) } } - From 8135e0d9738850058f35bd7723e60c7b7d63a5cd Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sun, 1 Feb 2026 17:57:34 +0700 Subject: [PATCH 30/34] test: move e2e tests into apirouter --- cmd/e2e/helpers_test.go | 30 -- cmd/e2e/log_queries_test.go | 10 - cmd/e2e/retry_test.go | 5 - cmd/e2e/signatures_test.go | 198 ------------ .../apirouter/destination_credentials_test.go | 300 ++++++++++++++++++ internal/apirouter/router_test.go | 16 +- 6 files changed, 314 insertions(+), 245 deletions(-) create mode 100644 internal/apirouter/destination_credentials_test.go diff --git a/cmd/e2e/helpers_test.go b/cmd/e2e/helpers_test.go index a16a0d86..edfb6208 100644 --- a/cmd/e2e/helpers_test.go +++ b/cmd/e2e/helpers_test.go @@ -67,11 +67,6 @@ type mockServerEvent struct { Payload map[string]interface{} `json:"payload"` } -type tokenResponse struct { - Token string `json:"token"` - TenantID string `json:"tenant_id"` -} - // ============================================================================= // Mock destination wrapper // ============================================================================= @@ -139,12 +134,6 @@ func (s *basicSuite) doJSON(method, url string, body any, result any) int { return s.doJSONWithAuth(method, url, fmt.Sprintf("Bearer %s", s.config.APIKey), body, result) } -// doJSONWithToken sends a request with a specific Bearer token. -func (s *basicSuite) doJSONWithToken(method, url string, token string, body any, result any) int { - s.T().Helper() - return s.doJSONWithAuth(method, url, fmt.Sprintf("Bearer %s", token), body, result) -} - // doJSONRaw sends a request without any auth header. func (s *basicSuite) doJSONRaw(method, url string, body any, result any) int { s.T().Helper() @@ -303,16 +292,6 @@ func (s *basicSuite) publish(tenantID, topic string, data map[string]any, opts . return resp } -// jwtFor returns a JWT token for the given tenant. -func (s *basicSuite) jwtFor(tenantID string) string { - s.T().Helper() - var resp tokenResponse - status := s.doJSON(http.MethodGet, s.apiURL("/tenants/"+tenantID+"/token"), nil, &resp) - s.Require().Equal(http.StatusOK, status, "failed to get token for tenant %s", tenantID) - s.Require().NotEmpty(resp.Token) - return resp.Token -} - // getDestination returns a destination. func (s *basicSuite) getDestination(tenantID, destID string) destinationResponse { s.T().Helper() @@ -322,15 +301,6 @@ func (s *basicSuite) getDestination(tenantID, destID string) destinationResponse return resp } -// updateDestination patches a destination. -func (s *basicSuite) updateDestination(tenantID, destID string, body map[string]any) destinationResponse { - s.T().Helper() - var resp destinationResponse - status := s.doJSON(http.MethodPatch, s.apiURL(fmt.Sprintf("/tenants/%s/destinations/%s", tenantID, destID)), body, &resp) - s.Require().Equal(http.StatusOK, status, "failed to update destination %s", destID) - return resp -} - // disableDestination disables a destination. func (s *basicSuite) disableDestination(tenantID, destID string) { s.T().Helper() diff --git a/cmd/e2e/log_queries_test.go b/cmd/e2e/log_queries_test.go index 79b30ea2..ec7db3c5 100644 --- a/cmd/e2e/log_queries_test.go +++ b/cmd/e2e/log_queries_test.go @@ -175,11 +175,6 @@ func (s *basicSuite) TestLogQueries_Events() { s.NotNil(resp["data"]) }) - s.Run("retrieve non-existent event returns 404", func() { - status := s.doJSON(http.MethodGet, s.apiURL("/events/"+idgen.Event()), nil, nil) - s.Equal(http.StatusNotFound, status) - }) - s.Run("filter by time[gte] excludes past events", func() { futureTime := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339) var resp struct { @@ -223,11 +218,6 @@ func (s *basicSuite) TestLogQueries_SortOrder() { s.True(curr.Before(next) || curr.Equal(next), "events not in ascending order at index %d", i) } }) - - s.Run("events invalid dir returns 422", func() { - status := s.doJSON(http.MethodGet, s.apiURL("/events?tenant_id="+setup.tenantID+"&dir=invalid"), nil, nil) - s.Equal(http.StatusUnprocessableEntity, status) - }) } func (s *basicSuite) TestLogQueries_Pagination() { diff --git a/cmd/e2e/retry_test.go b/cmd/e2e/retry_test.go index 3b6c460f..cdb78aab 100644 --- a/cmd/e2e/retry_test.go +++ b/cmd/e2e/retry_test.go @@ -93,11 +93,6 @@ func (s *basicSuite) TestRetry_ManualRetryCreatesNewAttempt() { s.Equal(1, manualCount, "should have exactly one manual retry attempt") } -func (s *basicSuite) TestRetry_ManualRetryNonExistentEvent() { - status := s.retryEvent(idgen.Event(), idgen.Destination()) - s.Equal(http.StatusNotFound, status) -} - func (s *basicSuite) TestRetry_ManualRetryOnDisabledDestinationRejected() { tenant := s.createTenant() dest := s.createWebhookDestination(tenant.ID, "*") diff --git a/cmd/e2e/signatures_test.go b/cmd/e2e/signatures_test.go index 51e0d1ed..b64e03e5 100644 --- a/cmd/e2e/signatures_test.go +++ b/cmd/e2e/signatures_test.go @@ -1,7 +1,6 @@ package e2e_test import ( - "net/http" "time" ) @@ -45,200 +44,3 @@ func (s *basicSuite) TestWebhookSignatures_WrongSecretFailsVerification() { s.True(events[0].Success, "delivery should still succeed") s.False(events[0].Verified, "signature should NOT be verified with wrong secret") } - -func (s *basicSuite) TestWebhookSignatures_SecretAutoGeneratedOnCreate() { - tenant := s.createTenant() - dest := s.createWebhookDestination(tenant.ID, "*") // No secret specified - - got := s.getDestination(tenant.ID, dest.ID) - s.Require().NotEmpty(got.Credentials["secret"], "secret should be auto-generated") - s.Require().GreaterOrEqual(len(got.Credentials["secret"]), 32, "auto-generated secret should be at least 32 chars") -} - -func (s *basicSuite) TestWebhookSignatures_SecretRotationViaAPI() { - tenant := s.createTenant() - dest := s.createWebhookDestination(tenant.ID, "*") // Auto-generated secret - - initial := s.getDestination(tenant.ID, dest.ID) - initialSecret := initial.Credentials["secret"] - s.Require().NotEmpty(initialSecret) - s.Require().Empty(initial.Credentials["previous_secret"]) - s.Require().Empty(initial.Credentials["previous_secret_invalid_at"]) - - // Rotate secret - s.updateDestination(tenant.ID, dest.ID, map[string]any{ - "credentials": map[string]any{ - "rotate_secret": true, - }, - }) - - // Verify rotation - rotated := s.getDestination(tenant.ID, dest.ID) - s.NotEmpty(rotated.Credentials["secret"]) - s.NotEqual(initialSecret, rotated.Credentials["secret"], "secret should have changed") - s.Equal(initialSecret, rotated.Credentials["previous_secret"], "previous_secret should be the old secret") - s.NotEmpty(rotated.Credentials["previous_secret_invalid_at"], "previous_secret_invalid_at should be set") -} - -func (s *basicSuite) TestWebhookSignatures_TenantCannotSetCustomSecret() { - tenant := s.createTenant() - token := s.jwtFor(tenant.ID) - destID := "dest_tenant_secret_test" - - // Attempt to create destination with secret via JWT → 422 - var errResp map[string]any - status := s.doJSONWithToken(http.MethodPost, s.apiURL("/tenants/"+tenant.ID+"/destinations"), token, map[string]any{ - "id": destID, - "type": "webhook", - "topics": "*", - "config": map[string]any{ - "url": s.mockServerURL() + "/webhook/" + destID, - }, - "credentials": map[string]any{ - "secret": "any-secret", - }, - }, &errResp) - s.Equal(http.StatusUnprocessableEntity, status) - s.Equal("validation error", errResp["message"]) - - // Create destination without secret via JWT → 201 - var createResp map[string]any - status = s.doJSONWithToken(http.MethodPost, s.apiURL("/tenants/"+tenant.ID+"/destinations"), token, map[string]any{ - "id": destID, - "type": "webhook", - "topics": "*", - "config": map[string]any{ - "url": s.mockServerURL() + "/webhook/" + destID, - }, - }, &createResp) - s.Require().Equal(http.StatusCreated, status) - - // PATCH with secret via JWT → 422 - status = s.doJSONWithToken(http.MethodPatch, s.apiURL("/tenants/"+tenant.ID+"/destinations/"+destID), token, map[string]any{ - "credentials": map[string]any{ - "secret": "new-secret", - }, - }, &errResp) - s.Equal(http.StatusUnprocessableEntity, status) - - // PATCH with previous_secret via JWT → 422 - status = s.doJSONWithToken(http.MethodPatch, s.apiURL("/tenants/"+tenant.ID+"/destinations/"+destID), token, map[string]any{ - "credentials": map[string]any{ - "previous_secret": "another-secret", - }, - }, &errResp) - s.Equal(http.StatusUnprocessableEntity, status) - - // PATCH with previous_secret_invalid_at via JWT → 422 - status = s.doJSONWithToken(http.MethodPatch, s.apiURL("/tenants/"+tenant.ID+"/destinations/"+destID), token, map[string]any{ - "credentials": map[string]any{ - "previous_secret_invalid_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339), - }, - }, &errResp) - s.Equal(http.StatusUnprocessableEntity, status) - - // PATCH with rotate_secret:true via JWT → 200 (allowed) - var rotateResp map[string]any - status = s.doJSONWithToken(http.MethodPatch, s.apiURL("/tenants/"+tenant.ID+"/destinations/"+destID), token, map[string]any{ - "credentials": map[string]any{ - "rotate_secret": true, - }, - }, &rotateResp) - s.Equal(http.StatusOK, status) -} - -func (s *basicSuite) TestWebhookSignatures_AdminCanSetCustomSecret() { - tenant := s.createTenant() - secret := testSecret - newSecret := testSecretAlt - - // Create destination with explicit secret - dest := s.createWebhookDestination(tenant.ID, "*", withSecret(secret)) - - // Verify custom secret was set - got := s.getDestination(tenant.ID, dest.ID) - s.Equal(secret, got.Credentials["secret"]) - - // PATCH with new secret directly - s.updateDestination(tenant.ID, dest.ID, map[string]any{ - "credentials": map[string]any{ - "secret": newSecret, - }, - }) - got = s.getDestination(tenant.ID, dest.ID) - s.Equal(newSecret, got.Credentials["secret"]) - - // Set previous_secret + previous_secret_invalid_at - gracePeriod := time.Now().Add(24 * time.Hour).Format(time.RFC3339) - s.updateDestination(tenant.ID, dest.ID, map[string]any{ - "credentials": map[string]any{ - "secret": newSecret, - "previous_secret": secret, - "previous_secret_invalid_at": gracePeriod, - }, - }) - got = s.getDestination(tenant.ID, dest.ID) - s.Equal(secret, got.Credentials["previous_secret"]) - s.NotEmpty(got.Credentials["previous_secret_invalid_at"]) - - // Validation: empty secret → 422 - var errResp map[string]any - status := s.doJSON(http.MethodPatch, s.apiURL("/tenants/"+tenant.ID+"/destinations/"+dest.ID), map[string]any{ - "credentials": map[string]any{ - "secret": "", - "previous_secret": secret, - "previous_secret_invalid_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339), - }, - }, &errResp) - s.Equal(http.StatusUnprocessableEntity, status) - - // Validation: invalid date format → 422 - status = s.doJSON(http.MethodPatch, s.apiURL("/tenants/"+tenant.ID+"/destinations/"+dest.ID), map[string]any{ - "credentials": map[string]any{ - "secret": newSecret, - "previous_secret": secret, - "previous_secret_invalid_at": "invalid-date", - }, - }, &errResp) - s.Equal(http.StatusUnprocessableEntity, status) - - // Validation: rotate_secret on create → 422 - status = s.doJSON(http.MethodPost, s.apiURL("/tenants/"+tenant.ID+"/destinations"), map[string]any{ - "id": dest.ID + "-rotate", - "type": "webhook", - "topics": "*", - "config": map[string]any{ - "url": s.mockServerURL() + "/webhook/" + dest.ID, - }, - "credentials": map[string]any{ - "rotate_secret": true, - }, - }, &errResp) - s.Equal(http.StatusUnprocessableEntity, status) - - // Rotate secret as admin - s.updateDestination(tenant.ID, dest.ID, map[string]any{ - "credentials": map[string]any{ - "rotate_secret": true, - }, - }) - - // Verify rotation worked - got = s.getDestination(tenant.ID, dest.ID) - s.NotEqual(newSecret, got.Credentials["secret"], "secret should have changed after rotation") - s.NotEmpty(got.Credentials["previous_secret"]) - s.NotEmpty(got.Credentials["previous_secret_invalid_at"]) - - // Admin unset previous_secret - s.updateDestination(tenant.ID, dest.ID, map[string]any{ - "credentials": map[string]any{ - "previous_secret": "", - "previous_secret_invalid_at": "", - }, - }) - - got = s.getDestination(tenant.ID, dest.ID) - s.NotEmpty(got.Credentials["secret"], "secret should still exist") - s.Empty(got.Credentials["previous_secret"], "previous_secret should be cleared") - s.Empty(got.Credentials["previous_secret_invalid_at"], "previous_secret_invalid_at should be cleared") -} diff --git a/internal/apirouter/destination_credentials_test.go b/internal/apirouter/destination_credentials_test.go new file mode 100644 index 00000000..3ea7871b --- /dev/null +++ b/internal/apirouter/destination_credentials_test.go @@ -0,0 +1,300 @@ +package apirouter_test + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + "time" + + "github.com/hookdeck/outpost/internal/destregistry" + destregistrydefault "github.com/hookdeck/outpost/internal/destregistry/providers" + "github.com/hookdeck/outpost/internal/util/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// webhookStandardRegistry creates a registry with the real webhook standard +// provider registered as "webhook". This is needed because testutil.Registry +// uses the default (non-standard) webhook provider. +func webhookStandardRegistry(t *testing.T) destregistry.Registry { + t.Helper() + logger := testutil.CreateTestLogger(t) + reg := destregistry.NewRegistry(&destregistry.Config{}, logger) + err := destregistrydefault.RegisterDefault(reg, destregistrydefault.RegisterDefaultDestinationOptions{ + Webhook: &destregistrydefault.DestWebhookConfig{ + Mode: "standard", + }, + }) + require.NoError(t, err) + return reg +} + +func TestDestinationCredentials_SecretAutoGeneratedOnCreate(t *testing.T) { + h := newAPITest(t, withDestRegistry(webhookStandardRegistry(t))) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + // Create destination without specifying a secret + req := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", map[string]any{ + "type": "webhook", + "topics": []string{"user.created"}, + "config": map[string]string{"url": "https://example.com/hook"}, + }) + resp := h.do(h.withAPIKey(req)) + require.Equal(t, http.StatusCreated, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + + assert.NotEmpty(t, dest.Credentials["secret"], "secret should be auto-generated") + assert.True(t, strings.HasPrefix(dest.Credentials["secret"], "whsec_"), + "auto-generated secret should have whsec_ prefix") +} + +func TestDestinationCredentials_SecretRotationViaAPI(t *testing.T) { + h := newAPITest(t, withDestRegistry(webhookStandardRegistry(t))) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + // Create destination (secret auto-generated) + createReq := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", map[string]any{ + "id": "d1", + "type": "webhook", + "topics": []string{"user.created"}, + "config": map[string]string{"url": "https://example.com/hook"}, + }) + createResp := h.do(h.withAPIKey(createReq)) + require.Equal(t, http.StatusCreated, createResp.Code) + + var created destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(createResp.Body.Bytes(), &created)) + initialSecret := created.Credentials["secret"] + require.NotEmpty(t, initialSecret) + assert.Empty(t, created.Credentials["previous_secret"]) + assert.Empty(t, created.Credentials["previous_secret_invalid_at"]) + + // Rotate secret + rotateReq := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "credentials": map[string]any{ + "rotate_secret": true, + }, + }) + rotateResp := h.do(h.withAPIKey(rotateReq)) + require.Equal(t, http.StatusOK, rotateResp.Code) + + var rotated destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(rotateResp.Body.Bytes(), &rotated)) + + assert.NotEmpty(t, rotated.Credentials["secret"]) + assert.NotEqual(t, initialSecret, rotated.Credentials["secret"], "secret should have changed") + assert.Equal(t, initialSecret, rotated.Credentials["previous_secret"], + "previous_secret should be the old secret") + assert.NotEmpty(t, rotated.Credentials["previous_secret_invalid_at"], + "previous_secret_invalid_at should be set") +} + +func TestDestinationCredentials_TenantCannotSetCustomSecret(t *testing.T) { + h := newAPITest(t, withDestRegistry(webhookStandardRegistry(t))) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + t.Run("create with secret via JWT returns 422", func(t *testing.T) { + req := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", map[string]any{ + "type": "webhook", + "topics": []string{"user.created"}, + "config": map[string]string{"url": "https://example.com/hook"}, + "credentials": map[string]any{ + "secret": "any-secret", + }, + }) + resp := h.do(h.withJWT(req, "t1")) + + assert.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + // Create destination without secret via JWT (should succeed) + createReq := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", map[string]any{ + "id": "d1", + "type": "webhook", + "topics": []string{"user.created"}, + "config": map[string]string{"url": "https://example.com/hook"}, + }) + createResp := h.do(h.withJWT(createReq, "t1")) + require.Equal(t, http.StatusCreated, createResp.Code) + + t.Run("patch with secret via JWT returns 422", func(t *testing.T) { + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "credentials": map[string]any{ + "secret": "new-secret", + }, + }) + resp := h.do(h.withJWT(req, "t1")) + + assert.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + t.Run("patch with previous_secret via JWT returns 422", func(t *testing.T) { + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "credentials": map[string]any{ + "previous_secret": "another-secret", + }, + }) + resp := h.do(h.withJWT(req, "t1")) + + assert.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + t.Run("patch with previous_secret_invalid_at via JWT returns 422", func(t *testing.T) { + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "credentials": map[string]any{ + "previous_secret_invalid_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339), + }, + }) + resp := h.do(h.withJWT(req, "t1")) + + assert.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + t.Run("patch with rotate_secret via JWT succeeds", func(t *testing.T) { + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "credentials": map[string]any{ + "rotate_secret": true, + }, + }) + resp := h.do(h.withJWT(req, "t1")) + + assert.Equal(t, http.StatusOK, resp.Code) + }) +} + +func TestDestinationCredentials_AdminCanSetCustomSecret(t *testing.T) { + h := newAPITest(t, withDestRegistry(webhookStandardRegistry(t))) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + secret := "whsec_dGVzdHNlY3JldDEyMzQ1Njc4OTBhYmNkZWY=" + newSecret := "whsec_dGVzdHNlY3JldDA5ODc2NTQzMjF6eXh3dnU=" + + // Create destination with explicit secret + createReq := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", map[string]any{ + "id": "d1", + "type": "webhook", + "topics": []string{"user.created"}, + "config": map[string]string{"url": "https://example.com/hook"}, + "credentials": map[string]any{ + "secret": secret, + }, + }) + createResp := h.do(h.withAPIKey(createReq)) + require.Equal(t, http.StatusCreated, createResp.Code) + + var created destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(createResp.Body.Bytes(), &created)) + assert.Equal(t, secret, created.Credentials["secret"]) + + t.Run("patch with new secret directly", func(t *testing.T) { + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "credentials": map[string]any{ + "secret": newSecret, + }, + }) + resp := h.do(h.withAPIKey(req)) + require.Equal(t, http.StatusOK, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + assert.Equal(t, newSecret, dest.Credentials["secret"]) + }) + + t.Run("set previous_secret and previous_secret_invalid_at", func(t *testing.T) { + gracePeriod := time.Now().Add(24 * time.Hour).Format(time.RFC3339) + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "credentials": map[string]any{ + "secret": newSecret, + "previous_secret": secret, + "previous_secret_invalid_at": gracePeriod, + }, + }) + resp := h.do(h.withAPIKey(req)) + require.Equal(t, http.StatusOK, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + assert.Equal(t, secret, dest.Credentials["previous_secret"]) + assert.NotEmpty(t, dest.Credentials["previous_secret_invalid_at"]) + }) + + t.Run("empty secret returns 422", func(t *testing.T) { + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "credentials": map[string]any{ + "secret": "", + "previous_secret": secret, + "previous_secret_invalid_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339), + }, + }) + resp := h.do(h.withAPIKey(req)) + + assert.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + t.Run("invalid date format returns 422", func(t *testing.T) { + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "credentials": map[string]any{ + "secret": newSecret, + "previous_secret": secret, + "previous_secret_invalid_at": "invalid-date", + }, + }) + resp := h.do(h.withAPIKey(req)) + + assert.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + t.Run("rotate_secret on create returns 422", func(t *testing.T) { + req := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", map[string]any{ + "id": "d1-rotate", + "type": "webhook", + "topics": []string{"user.created"}, + "config": map[string]string{"url": "https://example.com/hook"}, + "credentials": map[string]any{ + "rotate_secret": true, + }, + }) + resp := h.do(h.withAPIKey(req)) + + assert.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + t.Run("rotate secret as admin", func(t *testing.T) { + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "credentials": map[string]any{ + "rotate_secret": true, + }, + }) + resp := h.do(h.withAPIKey(req)) + require.Equal(t, http.StatusOK, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + assert.NotEqual(t, newSecret, dest.Credentials["secret"], + "secret should have changed after rotation") + assert.NotEmpty(t, dest.Credentials["previous_secret"]) + assert.NotEmpty(t, dest.Credentials["previous_secret_invalid_at"]) + }) + + t.Run("admin unset previous_secret", func(t *testing.T) { + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "credentials": map[string]any{ + "previous_secret": "", + "previous_secret_invalid_at": "", + }, + }) + resp := h.do(h.withAPIKey(req)) + require.Equal(t, http.StatusOK, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + assert.NotEmpty(t, dest.Credentials["secret"], "secret should still exist") + assert.Empty(t, dest.Credentials["previous_secret"], + "previous_secret should be cleared") + assert.Empty(t, dest.Credentials["previous_secret_invalid_at"], + "previous_secret_invalid_at should be cleared") + }) +} diff --git a/internal/apirouter/router_test.go b/internal/apirouter/router_test.go index c25837fd..66f036ee 100644 --- a/internal/apirouter/router_test.go +++ b/internal/apirouter/router_test.go @@ -57,7 +57,8 @@ type apiTest struct { type apiTestOption func(*apiTestConfig) type apiTestConfig struct { - tenantStore tenantstore.TenantStore + tenantStore tenantstore.TenantStore + destRegistry destregistry.Registry } func withTenantStore(ts tenantstore.TenantStore) apiTestOption { @@ -66,6 +67,12 @@ func withTenantStore(ts tenantstore.TenantStore) apiTestOption { } } +func withDestRegistry(r destregistry.Registry) apiTestOption { + return func(cfg *apiTestConfig) { + cfg.destRegistry = r + } +} + func newAPITest(t *testing.T, opts ...apiTestOption) *apiTest { t.Helper() @@ -82,13 +89,18 @@ func newAPITest(t *testing.T, opts ...apiTestOption) *apiTest { dp := &mockDeliveryPublisher{} eh := &mockEventHandler{} + var registry destregistry.Registry = &stubRegistry{} + if cfg.destRegistry != nil { + registry = cfg.destRegistry + } + router := apirouter.NewRouter( apirouter.RouterConfig{ ServiceName: "test", APIKey: testAPIKey, JWTSecret: testJWTSecret, Topics: testutil.TestTopics, - Registry: &stubRegistry{}, + Registry: registry, PortalConfig: portal.PortalConfig{}, }, apirouter.RouterDeps{ From c9a9aa53788279c5f07b2a500cca2cfd3da5ed89 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sun, 1 Feb 2026 18:22:51 +0700 Subject: [PATCH 31/34] fix: attempt response schema --- cmd/e2e/delivery_pipeline_test.go | 2 +- cmd/e2e/log_queries_test.go | 4 +- docs/apis/openapi.yaml | 58 +++++++++++++------------ internal/apirouter/log_handlers.go | 14 +++--- internal/apirouter/log_handlers_test.go | 4 +- 5 files changed, 41 insertions(+), 41 deletions(-) diff --git a/cmd/e2e/delivery_pipeline_test.go b/cmd/e2e/delivery_pipeline_test.go index cf036c22..014b3f5c 100644 --- a/cmd/e2e/delivery_pipeline_test.go +++ b/cmd/e2e/delivery_pipeline_test.go @@ -27,7 +27,7 @@ func (s *basicSuite) TestDeliveryPipeline_PublishDeliversToWebhook() { s.Require().GreaterOrEqual(len(attempts), 1) first := attempts[0] s.NotEmpty(first["id"]) - s.Equal(dest.ID, first["destination"]) + s.Equal(dest.ID, first["destination_id"]) s.NotEmpty(first["status"]) } diff --git a/cmd/e2e/log_queries_test.go b/cmd/e2e/log_queries_test.go index ec7db3c5..328ee0a9 100644 --- a/cmd/e2e/log_queries_test.go +++ b/cmd/e2e/log_queries_test.go @@ -75,8 +75,8 @@ func (s *basicSuite) TestLogQueries_Attempts() { first := resp.Models[0] s.NotEmpty(first["id"]) - s.NotEmpty(first["event"]) - s.Equal(setup.destinationID, first["destination"]) + s.NotEmpty(first["event_id"]) + s.Equal(setup.destinationID, first["destination_id"]) s.NotEmpty(first["status"]) s.NotEmpty(first["delivered_at"]) s.Equal(float64(0), first["attempt_number"]) diff --git a/docs/apis/openapi.yaml b/docs/apis/openapi.yaml index 34933f17..a01a13c7 100644 --- a/docs/apis/openapi.yaml +++ b/docs/apis/openapi.yaml @@ -1805,18 +1805,19 @@ components: type: boolean description: Whether this attempt was manually triggered (e.g., a retry initiated by a user). example: false + event_id: + type: string + description: The ID of the associated event. + example: "evt_123" + destination_id: + type: string + description: The destination ID this attempt was sent to. + example: "des_456" event: oneOf: - - type: string - description: Event ID (default, no expansion). - example: "evt_123" - $ref: "#/components/schemas/EventSummary" - $ref: "#/components/schemas/EventFull" - description: The associated event. Returns event ID by default, or included event object when include=event or include=event.data. - destination: - type: string - description: The destination ID this attempt was sent to. - example: "des_456" + description: The associated event object. Only present when include=event or include=event.data. EventSummary: type: object description: Event object without data (returned when include=event). @@ -2584,15 +2585,15 @@ paths: delivered_at: "2024-01-01T00:00:05Z" code: "200" attempt_number: 1 - event: "evt_123" - destination: "des_456" + event_id: "evt_123" + destination_id: "des_456" - id: "att_124" status: "failed" delivered_at: "2024-01-02T10:00:01Z" code: "503" attempt_number: 2 - event: "evt_789" - destination: "des_789" + event_id: "evt_789" + destination_id: "des_789" pagination: order_by: "time" dir: "desc" @@ -2608,13 +2609,14 @@ paths: delivered_at: "2024-01-01T00:00:05Z" code: "200" attempt_number: 1 + event_id: "evt_123" + destination_id: "des_456" event: id: "evt_123" topic: "user.created" time: "2024-01-01T00:00:00Z" eligible_for_retry: false metadata: { "source": "crm" } - destination: "des_456" pagination: order_by: "time" dir: "desc" @@ -3141,15 +3143,15 @@ paths: delivered_at: "2024-01-01T00:00:05Z" code: "200" attempt_number: 1 - event: "evt_123" - destination: "des_456" + event_id: "evt_123" + destination_id: "des_456" - id: "atm_124" status: "failed" delivered_at: "2024-01-02T10:00:01Z" code: "503" attempt_number: 2 - event: "evt_789" - destination: "des_456" + event_id: "evt_789" + destination_id: "des_456" pagination: order_by: "time" dir: "desc" @@ -3220,8 +3222,8 @@ paths: delivered_at: "2024-01-01T00:00:05Z" code: "200" attempt_number: 1 - event: "evt_123" - destination: "des_456" + event_id: "evt_123" + destination_id: "des_456" "404": description: Tenant, Destination, or Attempt not found. @@ -3679,15 +3681,15 @@ paths: delivered_at: "2024-01-01T00:00:05Z" code: "200" attempt_number: 1 - event: "evt_123" - destination: "des_456" + event_id: "evt_123" + destination_id: "des_456" - id: "att_124" status: "failed" delivered_at: "2024-01-02T10:00:01Z" code: "503" attempt_number: 2 - event: "evt_789" - destination: "des_456" + event_id: "evt_789" + destination_id: "des_456" pagination: order_by: "time" dir: "desc" @@ -3703,13 +3705,14 @@ paths: delivered_at: "2024-01-01T00:00:05Z" code: "200" attempt_number: 1 + event_id: "evt_123" + destination_id: "des_456" event: id: "evt_123" topic: "user.created" time: "2024-01-01T00:00:00Z" eligible_for_retry: false metadata: { "source": "crm" } - destination: "des_456" pagination: order_by: "time" dir: "desc" @@ -3774,8 +3777,8 @@ paths: delivered_at: "2024-01-01T00:00:05Z" code: "200" attempt_number: 1 - event: "evt_123" - destination: "des_456" + event_id: "evt_123" + destination_id: "des_456" AttemptWithIncludeExample: summary: Response with include=event.data,response_data value: @@ -3788,6 +3791,8 @@ paths: body: '{"status":"ok"}' headers: { "content-type": "application/json" } attempt_number: 1 + event_id: "evt_123" + destination_id: "des_456" event: id: "evt_123" topic: "user.created" @@ -3795,7 +3800,6 @@ paths: eligible_for_retry: false metadata: { "source": "crm" } data: { "user_id": "userid", "status": "active" } - destination: "des_456" "404": description: Tenant or Attempt not found. diff --git a/internal/apirouter/log_handlers.go b/internal/apirouter/log_handlers.go index f50b410d..8f90c988 100644 --- a/internal/apirouter/log_handlers.go +++ b/internal/apirouter/log_handlers.go @@ -93,10 +93,9 @@ type APIAttempt struct { AttemptNumber int `json:"attempt_number"` Manual bool `json:"manual"` - // Expandable fields - string (ID) or object depending on expand - Event interface{} `json:"event"` - - Destination string `json:"destination"` + EventID string `json:"event_id"` + DestinationID string `json:"destination_id"` + Event interface{} `json:"event,omitempty"` } // APIEventSummary is the event object when expand=event (without data) @@ -149,7 +148,8 @@ func toAPIAttempt(ar *logstore.AttemptRecord, opts IncludeOptions) APIAttempt { Code: ar.Attempt.Code, AttemptNumber: ar.Attempt.AttemptNumber, Manual: ar.Attempt.Manual, - Destination: ar.Attempt.DestinationID, + EventID: ar.Attempt.EventID, + DestinationID: ar.Attempt.DestinationID, } if opts.ResponseData { @@ -174,11 +174,7 @@ func toAPIAttempt(ar *logstore.AttemptRecord, opts IncludeOptions) APIAttempt { EligibleForRetry: ar.Event.EligibleForRetry, Metadata: ar.Event.Metadata, } - } else { - api.Event = ar.Event.ID } - } else { - api.Event = ar.Attempt.EventID } return api diff --git a/internal/apirouter/log_handlers_test.go b/internal/apirouter/log_handlers_test.go index d6b6a545..44266ed5 100644 --- a/internal/apirouter/log_handlers_test.go +++ b/internal/apirouter/log_handlers_test.go @@ -1005,7 +1005,7 @@ func TestAPI_Attempts(t *testing.T) { var result apirouter.AttemptPaginatedResult require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) assert.Len(t, result.Models, 1) - assert.Equal(t, "d1", result.Models[0].Destination) + assert.Equal(t, "d1", result.Models[0].DestinationID) }) t.Run("excludes attempts from other tenants same destination", func(t *testing.T) { @@ -1110,7 +1110,7 @@ func TestAPI_Attempts(t *testing.T) { var attempt apirouter.APIAttempt require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &attempt)) assert.Equal(t, "a1", attempt.ID) - assert.Equal(t, "d1", attempt.Destination) + assert.Equal(t, "d1", attempt.DestinationID) }) t.Run("attempt belonging to different destination returns 404", func(t *testing.T) { From e309e4fb7a5d884c02ed7fae698fe56db968ae13 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Sun, 1 Feb 2026 20:48:51 +0700 Subject: [PATCH 32/34] chore: upgrade Dockerfiles to Go 1.24 Co-Authored-By: Claude Opus 4.5 --- build/Dockerfile.example | 2 +- build/dev/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build/Dockerfile.example b/build/Dockerfile.example index 207fc962..14c75fde 100644 --- a/build/Dockerfile.example +++ b/build/Dockerfile.example @@ -12,7 +12,7 @@ # Stage 0 # Build the binaries -FROM golang:1.23-alpine +FROM golang:1.24-alpine WORKDIR /app COPY go.mod go.sum ./ RUN go mod download diff --git a/build/dev/Dockerfile b/build/dev/Dockerfile index 5968a882..722a3f2d 100644 --- a/build/dev/Dockerfile +++ b/build/dev/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23-alpine AS fetch +FROM golang:1.24-alpine AS fetch RUN go install github.com/air-verse/air@v1.61.1 WORKDIR /app COPY . . From 5273fce59c9a13aa31acb58a1023001faebde01b Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Mon, 2 Feb 2026 20:03:51 +0700 Subject: [PATCH 33/34] chore: openapi.yaml --- docs/apis/openapi.yaml | 935 +++++++---------------------------------- 1 file changed, 155 insertions(+), 780 deletions(-) diff --git a/docs/apis/openapi.yaml b/docs/apis/openapi.yaml index a01a13c7..dc6491a2 100644 --- a/docs/apis/openapi.yaml +++ b/docs/apis/openapi.yaml @@ -1707,6 +1707,21 @@ components: type: boolean description: Whether this event was already processed (idempotency hit). If true, the event was not queued again. example: false + RetryRequest: + type: object + description: Request body for retrying event delivery to a destination. + required: + - event_id + - destination_id + properties: + event_id: + type: string + description: The ID of the event to retry. + example: "evt_123" + destination_id: + type: string + description: The ID of the destination to deliver to. + example: "des_456" Event: type: object properties: @@ -2457,6 +2472,42 @@ paths: schema: $ref: "#/components/schemas/APIErrorResponse" + /events/{event_id}: + parameters: + - name: event_id + in: path + required: true + schema: + type: string + description: The ID of the event. + get: + tags: [Events] + summary: Get Event + description: | + Retrieves details for a specific event. + + When authenticated with a Tenant JWT, only events belonging to that tenant can be accessed. + When authenticated with Admin API Key, events from any tenant can be accessed. + operationId: getEvent + responses: + "200": + description: Event details. + content: + application/json: + schema: + $ref: "#/components/schemas/Event" + examples: + EventExample: + value: + id: "evt_123" + topic: "user.created" + time: "2024-01-01T00:00:00Z" + eligible_for_retry: false + metadata: { "source": "crm" } + data: { "user_id": "userid", "status": "active" } + "404": + description: Event not found. + /attempts: get: tags: [Attempts] @@ -2632,6 +2683,79 @@ paths: schema: $ref: "#/components/schemas/APIErrorResponse" + /attempts/{attempt_id}: + parameters: + - name: attempt_id + in: path + required: true + schema: + type: string + description: The ID of the attempt. + get: + tags: [Attempts] + summary: Get Attempt + description: | + Retrieves details for a specific attempt. + + When authenticated with a Tenant JWT, only attempts belonging to that tenant can be accessed. + When authenticated with Admin API Key, attempts from any tenant can be accessed. + operationId: getAttempt + parameters: + - name: include + in: query + required: false + schema: + oneOf: + - type: string + - type: array + items: + type: string + description: | + Fields to include in the response. Can be specified multiple times or comma-separated. + - `event`: Include event summary (id, topic, time, eligible_for_retry, metadata) + - `event.data`: Include full event with payload data + - `response_data`: Include response body and headers + responses: + "200": + description: Attempt details. + content: + application/json: + schema: + $ref: "#/components/schemas/Attempt" + examples: + AttemptExample: + value: + id: "atm_123" + status: "success" + delivered_at: "2024-01-01T00:00:05Z" + code: "200" + attempt_number: 1 + event_id: "evt_123" + destination_id: "des_456" + AttemptWithIncludeExample: + summary: Response with include=event.data,response_data + value: + id: "atm_123" + status: "success" + delivered_at: "2024-01-01T00:00:05Z" + code: "200" + response_data: + status_code: 200 + body: '{"status":"ok"}' + headers: { "content-type": "application/json" } + attempt_number: 1 + event_id: "evt_123" + destination_id: "des_456" + event: + id: "evt_123" + topic: "user.created" + time: "2024-01-01T00:00:00Z" + eligible_for_retry: false + metadata: { "source": "crm" } + data: { "user_id": "userid", "status": "active" } + "404": + description: Attempt not found. + /tenants/{tenant_id}/portal: parameters: - name: tenant_id @@ -3227,44 +3351,6 @@ paths: "404": description: Tenant, Destination, or Attempt not found. - /tenants/{tenant_id}/destinations/{destination_id}/attempts/{attempt_id}/retry: - parameters: - - name: tenant_id - in: path - required: true - schema: - type: string - description: The ID of the tenant. Required when using AdminApiKey authentication. - - name: destination_id - in: path - required: true - schema: - type: string - description: The ID of the destination. - - name: attempt_id - in: path - required: true - schema: - type: string - description: The ID of the attempt to retry. - post: - tags: [Destinations] - summary: Retry Destination Attempt - description: | - Triggers a retry for an attempt scoped to a destination. Only the latest attempt for an event+destination pair can be retried. - The destination must exist and be enabled. - operationId: retryTenantDestinationAttempt - responses: - "202": - description: Retry accepted for processing. - "404": - description: Tenant, Destination, or Attempt not found. - "409": - description: | - Attempt not eligible for retry. This can happen when: - - The attempt is not the latest for this event+destination pair - - The destination is disabled or deleted - # Publish (Admin Only) /publish: post: @@ -3295,765 +3381,54 @@ paths: description: Unprocessable Entity. The event topic was either required or was invalid. # Add other error responses - # Schemas (Tenant Specific - Admin or JWT) - /tenants/{tenant_id}/destination-types: - parameters: - - name: tenant_id - in: path + # Retry + /retry: + post: + tags: [Attempts] + summary: Retry Event Delivery + description: | + Triggers a retry for delivering an event to a destination. The event must exist and the destination must be enabled and match the event's topic. + + When authenticated with a Tenant JWT, only events belonging to that tenant can be retried. + When authenticated with Admin API Key, events from any tenant can be retried. + operationId: retryEvent + requestBody: required: true - schema: - type: string - description: The ID of the tenant. Required when using AdminApiKey authentication. - get: - tags: [Schemas] - summary: List Destination Type Schemas (for Tenant) - description: Returns a list of JSON-based input schemas for each available destination type. Requires Admin API Key or Tenant JWT. - operationId: listTenantDestinationTypeSchemas + content: + application/json: + schema: + $ref: "#/components/schemas/RetryRequest" responses: - "200": - description: A list of destination type schemas. + "202": + description: Retry accepted for processing. content: application/json: schema: - type: array - items: - $ref: "#/components/schemas/DestinationTypeSchema" + $ref: "#/components/schemas/SuccessResponse" examples: - DestinationTypesExample: + RetryAccepted: value: - - type: "webhook" - label: "Webhook" - description: "Send event via an HTTP POST request to a URL" - icon: "" - instructions: "Enter the URL..." - config_fields: [ - { - type: "text", - label: "URL", - description: "The URL to send the webhook to.", - pattern: "^https?://.*", # Example pattern - required: true, - }, - ] - credential_fields: [ - { - type: "text", - label: "Secret", - description: "Optional signing secret.", - required: false, - sensitive: true, # Added sensitive - }, - ] - - type: "aws_sqs" - label: "AWS SQS" - description: "Send event to an AWS SQS queue" - icon: "" - instructions: "Enter Queue URL..." - config_fields: - [ - { - type: "text", - label: "Queue URL", - description: "The URL of the SQS queue.", - required: true, - }, - { - type: "text", - label: "Endpoint", - description: "Optional custom AWS endpoint URL.", - required: false, - }, - ] - credential_fields: - [ - { - type: "text", - label: "Key", - description: "AWS Access Key ID.", - required: true, - sensitive: true, - }, - { - type: "text", - label: "Secret", - description: "AWS Secret Access Key.", - required: true, - sensitive: true, - }, - { - type: "text", - label: "Session", - description: "Optional AWS Session Token.", - required: false, - sensitive: true, - }, - ] - - type: "aws_s3" - label: "AWS S3" - description: "Store events in an Amazon S3 bucket" - icon: "" - instructions: "Enter bucket and region..." - config_fields: - [ - { - type: "text", - label: "Bucket Name", - description: "The name of the S3 bucket.", - required: true, - }, - { - type: "text", - label: "AWS Region", - description: "The AWS region where the bucket is located.", - required: true, - }, - ] - credential_fields: - [ - { - type: "text", - label: "Key", - description: "AWS Access Key ID.", - required: true, - sensitive: true, - }, - { - type: "text", - label: "Secret", - description: "AWS Secret Access Key.", - required: true, - sensitive: true, - }, - ] - - type: "aws_s3" - label: "AWS S3" - description: "Store events in an Amazon S3 bucket" - icon: "" - instructions: "Enter bucket and region..." - config_fields: - [ - { - type: "text", - label: "Bucket Name", - description: "The name of the S3 bucket.", - required: true, - }, - { - type: "text", - label: "AWS Region", - description: "The AWS region where the bucket is located.", - required: true, - }, - ] - credential_fields: - [ - { - type: "text", - label: "Key", - description: "AWS Access Key ID.", - required: true, - sensitive: true, - }, - { - type: "text", - label: "Secret", - description: "AWS Secret Access Key.", - required: true, - sensitive: true, - }, - ] - "404": - description: Tenant not found. - - /tenants/{tenant_id}/destination-types/{type}: - parameters: - - name: tenant_id - in: path - required: true - schema: - type: string - description: The ID of the tenant. Required when using AdminApiKey authentication. - - name: type - in: path - required: true - schema: - type: string - enum: [webhook, aws_sqs, rabbitmq, hookdeck, aws_kinesis, aws_s3] - description: The type of the destination. - get: - tags: [Schemas] - summary: Get Destination Type Schema (for Tenant) - description: Returns the input schema for a specific destination type. Requires Admin API Key or Tenant JWT. - operationId: getTenantDestinationTypeSchema - responses: - "200": - description: The schema for the specified destination type. - content: - application/json: - schema: - $ref: "#/components/schemas/DestinationTypeSchema" - examples: - WebhookSchemaExample: - value: - type: "webhook" - label: "Webhook" - description: "Send event via an HTTP POST request to a URL" - icon: "" - instructions: "Enter the URL..." - config_fields: [ - { - type: "text", - label: "URL", - description: "The URL to send the webhook to.", - pattern: "^https?://.*", # Example pattern - required: true, - }, - ] - credential_fields: [ - { - type: "text", - label: "Secret", - description: "Optional signing secret.", - required: false, - sensitive: true, # Added sensitive - }, - ] - "404": - description: Tenant or Destination type not found. - - # Topics (Tenant Specific - Admin or JWT) - /tenants/{tenant_id}/topics: - parameters: - - name: tenant_id - in: path - required: true - schema: - type: string - description: The ID of the tenant. Required when using AdminApiKey authentication. - get: - tags: [Topics] - summary: List Available Topics (for Tenant) - description: Returns a list of available event topics configured in the Outpost instance. Requires Admin API Key or Tenant JWT. - operationId: listTenantTopics - responses: - "200": - description: A list of topic names. - content: - application/json: - schema: - type: array - items: - type: string - examples: - TopicsListExample: - value: - [ - "user.created", - "user.updated", - "order.shipped", - "inventory.updated", - ] - "404": - description: Tenant not found. - - # Attempts (Tenant Specific - Admin or JWT) - /tenants/{tenant_id}/attempts: - parameters: - - name: tenant_id - in: path - required: true - schema: - type: string - description: The ID of the tenant. Required when using AdminApiKey authentication. - get: - tags: [Attempts] - summary: List Attempts - description: Retrieves a paginated list of attempts for the tenant, with filtering and sorting options. - operationId: listTenantAttempts - parameters: - - name: destination_id - in: query - required: false - schema: - type: string - description: Filter attempts by destination ID. - - name: event_id - in: query - required: false - schema: - type: string - description: Filter attempts by event ID. - - name: status - in: query - required: false - schema: - type: string - enum: [success, failed] - description: Filter attempts by status. - - name: topic - in: query - required: false - schema: - oneOf: - - type: string - - type: array - items: - type: string - description: Filter attempts by event topic(s). Can be specified multiple times or comma-separated. - - name: time[gte] - in: query - required: false - schema: - type: string - format: date-time - description: Filter attempts by event time >= value (RFC3339 or YYYY-MM-DD format). - - name: time[lte] - in: query - required: false - schema: - type: string - format: date-time - description: Filter attempts by event time <= value (RFC3339 or YYYY-MM-DD format). - - name: limit - in: query - required: false - schema: - type: integer - default: 100 - minimum: 1 - maximum: 1000 - description: Number of items per page (default 100, max 1000). - - name: next - in: query - required: false - schema: - type: string - description: Cursor for next page of results. - - name: prev - in: query - required: false - schema: - type: string - description: Cursor for previous page of results. - - name: include - in: query - required: false - schema: - oneOf: - - type: string - - type: array - items: - type: string - description: | - Fields to include in the response. Can be specified multiple times or comma-separated. - - `event`: Include event summary (id, topic, time, eligible_for_retry, metadata) - - `event.data`: Include full event with payload data - - `response_data`: Include response body and headers - - name: order_by - in: query - required: false - schema: - type: string - enum: [time] - default: time - description: Field to sort by. - - name: dir - in: query - required: false - schema: - type: string - enum: [asc, desc] - default: desc - description: Sort direction. - responses: - "200": - description: A paginated list of attempts. - content: - application/json: - schema: - $ref: "#/components/schemas/AttemptPaginatedResult" - examples: - AttemptsListExample: - value: - models: - - id: "atm_123" - status: "success" - delivered_at: "2024-01-01T00:00:05Z" - code: "200" - attempt_number: 1 - event_id: "evt_123" - destination_id: "des_456" - - id: "att_124" - status: "failed" - delivered_at: "2024-01-02T10:00:01Z" - code: "503" - attempt_number: 2 - event_id: "evt_789" - destination_id: "des_456" - pagination: - order_by: "time" - dir: "desc" - limit: 100 - next: "MTcwNDA2NzIwMA==" - prev: null - AttemptsWithIncludeExample: - summary: Response with include=event - value: - models: - - id: "atm_123" - status: "success" - delivered_at: "2024-01-01T00:00:05Z" - code: "200" - attempt_number: 1 - event_id: "evt_123" - destination_id: "des_456" - event: - id: "evt_123" - topic: "user.created" - time: "2024-01-01T00:00:00Z" - eligible_for_retry: false - metadata: { "source": "crm" } - pagination: - order_by: "time" - dir: "desc" - limit: 100 - next: null - prev: null - "404": - description: Tenant not found. - "422": - description: Validation error (invalid query parameters). - content: - application/json: - schema: - $ref: "#/components/schemas/APIErrorResponse" - - /tenants/{tenant_id}/attempts/{attempt_id}: - parameters: - - name: tenant_id - in: path - required: true - schema: - type: string - description: The ID of the tenant. Required when using AdminApiKey authentication. - - name: attempt_id - in: path - required: true - schema: - type: string - description: The ID of the attempt. - get: - tags: [Attempts] - summary: Get Attempt - description: Retrieves details for a specific attempt. - operationId: getTenantAttempt - parameters: - - name: include - in: query - required: false - schema: - oneOf: - - type: string - - type: array - items: - type: string - description: | - Fields to include in the response. Can be specified multiple times or comma-separated. - - `event`: Include event summary - - `event.data`: Include full event with payload data - - `response_data`: Include response body and headers - responses: - "200": - description: Attempt details. - content: - application/json: - schema: - $ref: "#/components/schemas/Attempt" - examples: - AttemptExample: - value: - id: "atm_123" - status: "success" - delivered_at: "2024-01-01T00:00:05Z" - code: "200" - attempt_number: 1 - event_id: "evt_123" - destination_id: "des_456" - AttemptWithIncludeExample: - summary: Response with include=event.data,response_data - value: - id: "atm_123" - status: "success" - delivered_at: "2024-01-01T00:00:05Z" - code: "200" - response_data: - status_code: 200 - body: '{"status":"ok"}' - headers: { "content-type": "application/json" } - attempt_number: 1 - event_id: "evt_123" - destination_id: "des_456" - event: - id: "evt_123" - topic: "user.created" - time: "2024-01-01T00:00:00Z" - eligible_for_retry: false - metadata: { "source": "crm" } - data: { "user_id": "userid", "status": "active" } - "404": - description: Tenant or Attempt not found. - - /tenants/{tenant_id}/attempts/{attempt_id}/retry: - parameters: - - name: tenant_id - in: path - required: true - schema: - type: string - description: The ID of the tenant. Required when using AdminApiKey authentication. - - name: attempt_id - in: path - required: true - schema: - type: string - description: The ID of the attempt to retry. - post: - tags: [Attempts] - summary: Retry Attempt - description: | - Triggers a retry for an attempt. Only the latest attempt for an event+destination pair can be retried. - The destination must exist and be enabled. - operationId: retryTenantAttempt - responses: - "202": - description: Retry accepted for processing. - "404": - description: Tenant or Attempt not found. - "409": + success: true + "400": description: | - Attempt not eligible for retry. This can happen when: - - The attempt is not the latest for this event+destination pair - - The destination is disabled or deleted - - # Events (Tenant Specific - Admin or JWT) - /tenants/{tenant_id}/events: - parameters: - - name: tenant_id - in: path - required: true - schema: - type: string - description: The ID of the tenant. Required when using AdminApiKey authentication. - get: - tags: [Events] - summary: List Events - description: Retrieves a list of events for the tenant, supporting cursor navigation and filtering. - operationId: listTenantEvents - parameters: - - name: destination_id - in: query - required: false - schema: - oneOf: - - type: string - - type: array - items: - type: string - description: Filter events by destination ID(s). - - name: status - in: query - required: false - schema: - type: string - enum: [success, failed] - description: Filter events by delivery status. - - name: next - in: query - required: false - schema: - type: string - description: Cursor for next page of results. - - name: prev - in: query - required: false - schema: - type: string - description: Cursor for previous page of results. - - name: limit - in: query - required: false - schema: - type: integer - default: 100 - minimum: 1 - maximum: 1000 - description: Number of items per page (default 100, max 1000). - - name: time[gte] - in: query - required: false - schema: - type: string - format: date-time - description: Filter events with time >= value (RFC3339 or YYYY-MM-DD format). - - name: time[lte] - in: query - required: false - schema: - type: string - format: date-time - description: Filter events with time <= value (RFC3339 or YYYY-MM-DD format). - - name: order_by - in: query - required: false - schema: - type: string - enum: [time] - default: time - description: Field to sort by. - - name: dir - in: query - required: false - schema: - type: string - enum: [asc, desc] - default: desc - description: Sort direction. - responses: - "200": - description: A paginated list of events. - content: - application/json: - schema: - $ref: "#/components/schemas/EventPaginatedResult" - examples: - EventsListExample: - value: - models: - - id: "evt_123" - destination_id: "des_456" - topic: "user.created" - time: "2024-01-01T00:00:00Z" - successful_at: "2024-01-01T00:00:05Z" - metadata: { "source": "crm" } - data: { "user_id": "userid", "status": "active" } - - id: "evt_789" - destination_id: "des_456" - topic: "order.shipped" - time: "2024-01-02T10:00:00Z" - successful_at: null - metadata: { "source": "oms" } - data: { "order_id": "orderid", "tracking": "1Z..." } - pagination: - order_by: "time" - dir: "desc" - limit: 100 - next: null - prev: null + Bad request. This can happen when: + - The destination is disabled + - The destination does not match the event's topic "404": - description: Tenant not found. + description: Event or destination not found. "422": - description: Validation error (invalid query parameters). + description: Validation error (missing required fields). content: application/json: schema: $ref: "#/components/schemas/APIErrorResponse" - /tenants/{tenant_id}/events/{event_id}: - parameters: - - name: tenant_id - in: path - required: true - schema: - type: string - description: The ID of the tenant. Required when using AdminApiKey authentication. - - name: event_id - in: path - required: true - schema: - type: string - description: The ID of the event. - get: - tags: [Events] - summary: Get Event - description: Retrieves details for a specific event. - operationId: getTenantEvent - responses: - "200": - description: Event details. - content: - application/json: - schema: - $ref: "#/components/schemas/Event" - examples: - EventExample: - value: - id: "evt_123" - destination_id: "des_456" - topic: "user.created" - time: "2024-01-01T00:00:00Z" - successful_at: "2024-01-01T00:00:05Z" - metadata: { "source": "crm" } - data: { "user_id": "userid", "status": "active" } - "404": - description: Tenant or Event not found. - - /tenants/{tenant_id}/events/{event_id}/attempts: - parameters: - - name: tenant_id - in: path - required: true - schema: - type: string - description: The ID of the tenant. Required when using AdminApiKey authentication. - - name: event_id - in: path - required: true - schema: - type: string - description: The ID of the event. - get: - tags: [Events] - summary: List Event Attempts - description: Retrieves a list of attempts for a specific event, including response details. - operationId: listTenantEventAttempts - responses: - "200": - description: A list of attempts. - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/DeliveryAttempt" - examples: - AttemptsListExample: - value: - - delivered_at: "2024-01-01T00:00:05Z" - status: "success" - response_status_code: 200 - response_body: '{"status":"ok"}' - response_headers: { "content-type": "application/json" } - - delivered_at: "2024-01-01T00:00:01Z" - status: "failed" - response_status_code: 503 - response_body: "Service Unavailable" - response_headers: { "content-type": "text/plain" } - "404": - description: Tenant or Event not found. - - # Tenant Agnostic Routes (JWT Auth Only) - Mirroring tenant-specific routes where AllowTenantFromJWT=true - - # Note: Portal routes (/portal, /token) still require AdminApiKey even when tenant is inferred from JWT, - # as per router.go logic (Mode=RouteModePortal, AuthScope=AuthScopeAdmin). - # They are included here for completeness of paths derived from AllowTenantFromJWT=true, - # but their security reflects the Admin requirement. - /destination-types: get: tags: [Schemas] - summary: List Destination Type Schemas (JWT Auth) - description: Returns a list of JSON-based input schemas for each available destination type (infers tenant from JWT). - operationId: listDestinationTypeSchemasJwt + summary: List Destination Type Schemas + description: Returns a list of JSON-based input schemas for each available destination type. + operationId: listDestinationTypeSchemas responses: "200": description: A list of destination type schemas. @@ -4192,7 +3567,7 @@ paths: /topics: get: tags: [Topics] - summary: List Available Topics) + summary: List Available Topics description: Returns a list of available event topics configured in the Outpost instance. operationId: listTopics responses: From 2f2ac11e80db3ef524c5a8e5f0ba68fa0cd9c556 Mon Sep 17 00:00:00 2001 From: Alex Luong Date: Mon, 2 Feb 2026 21:15:35 +0700 Subject: [PATCH 34/34] fix: portal using new endpoints (#671) * fix: use new endpoint structure * fix: destination attempt queries --- internal/portal/src/app.tsx | 55 ++++++++++------- .../RetryDeliveryButton.tsx | 14 +++-- .../RetryEventButton/RetryEventButton.tsx | 60 ------------------- internal/portal/src/destination-types.tsx | 8 ++- .../CreateDestination/CreateDestination.tsx | 29 +++++---- .../Destination/Events/AttemptDetails.tsx | 10 ++-- .../scenes/Destination/Events/Attempts.tsx | 11 ++-- internal/portal/src/typings/Event.ts | 8 ++- 8 files changed, 78 insertions(+), 117 deletions(-) delete mode 100644 internal/portal/src/common/RetryEventButton/RetryEventButton.tsx diff --git a/internal/portal/src/app.tsx b/internal/portal/src/app.tsx index 5c775984..be14f512 100644 --- a/internal/portal/src/app.tsx +++ b/internal/portal/src/app.tsx @@ -15,6 +15,7 @@ import CreateDestination from "./scenes/CreateDestination/CreateDestination"; type ApiClient = { fetch: (path: string, init?: RequestInit) => Promise; + fetchRoot: (path: string, init?: RequestInit) => Promise; }; // API error response from the server @@ -96,32 +97,42 @@ function AuthenticatedApp({ tenant: TenantResponse; token: string; }) { + const handleResponse = async (res: Response) => { + if (!res.ok) { + let error: ApiError; + try { + const data = await res.json(); + error = new ApiError( + data.message || res.statusText, + data.status || res.status, + Array.isArray(data.data) ? data.data : undefined, + ); + } catch (e) { + error = new ApiError(res.statusText, res.status); + } + throw error; + } + return res.json(); + }; + + const makeHeaders = (init?: RequestInit) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...init?.headers, + }); + const apiClient: ApiClient = { fetch: (path: string, init?: RequestInit) => { return fetch(`/api/v1/tenants/${tenant.id}/${path}`, { ...init, - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - ...init?.headers, - }, - }).then(async (res) => { - if (!res.ok) { - let error: ApiError; - try { - const data = await res.json(); - error = new ApiError( - data.message || res.statusText, - data.status || res.status, - Array.isArray(data.data) ? data.data : undefined, - ); - } catch (e) { - error = new ApiError(res.statusText, res.status); - } - throw error; - } - return res.json(); - }); + headers: makeHeaders(init), + }).then(handleResponse); + }, + fetchRoot: (path: string, init?: RequestInit) => { + return fetch(`/api/v1/${path}`, { + ...init, + headers: makeHeaders(init), + }).then(handleResponse); }, }; diff --git a/internal/portal/src/common/RetryDeliveryButton/RetryDeliveryButton.tsx b/internal/portal/src/common/RetryDeliveryButton/RetryDeliveryButton.tsx index c46d48d0..ffed12ef 100644 --- a/internal/portal/src/common/RetryDeliveryButton/RetryDeliveryButton.tsx +++ b/internal/portal/src/common/RetryDeliveryButton/RetryDeliveryButton.tsx @@ -5,7 +5,8 @@ import { showToast } from "../Toast/Toast"; import { ApiContext, formatError } from "../../app"; interface RetryDeliveryButtonProps { - attemptId: string; + eventId: string; + destinationId: string; disabled: boolean; loading: boolean; completed: (success: boolean) => void; @@ -14,7 +15,8 @@ interface RetryDeliveryButtonProps { } const RetryDeliveryButton: React.FC = ({ - attemptId, + eventId, + destinationId, disabled, loading, completed, @@ -29,8 +31,12 @@ const RetryDeliveryButton: React.FC = ({ e.stopPropagation(); setRetrying(true); try { - await apiClient.fetch(`attempts/${attemptId}/retry`, { + await apiClient.fetchRoot("retry", { method: "POST", + body: JSON.stringify({ + event_id: eventId, + destination_id: destinationId, + }), }); showToast("success", "Retry successful."); completed(true); @@ -41,7 +47,7 @@ const RetryDeliveryButton: React.FC = ({ setRetrying(false); }, - [apiClient, attemptId, completed], + [apiClient, eventId, destinationId, completed], ); return ( diff --git a/internal/portal/src/common/RetryEventButton/RetryEventButton.tsx b/internal/portal/src/common/RetryEventButton/RetryEventButton.tsx deleted file mode 100644 index c6435488..00000000 --- a/internal/portal/src/common/RetryEventButton/RetryEventButton.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React, { useCallback, useContext, useState, MouseEvent } from "react"; -import Button from "../Button/Button"; -import { ReplayIcon } from "../Icons"; -import { showToast } from "../Toast/Toast"; -import { ApiContext, formatError } from "../../app"; - -interface RetryEventButtonProps { - eventId: string; - destinationId: string; - disabled: boolean; - loading: boolean; - completed: (success: boolean) => void; -} - -const RetryEventButton: React.FC = ({ - eventId, - destinationId, - disabled, - loading, - completed, -}) => { - const apiClient = useContext(ApiContext); - const [retrying, setRetrying] = useState(false); - - const retryEvent = useCallback( - async (e: MouseEvent) => { - e.stopPropagation(); - setRetrying(true); - try { - await apiClient.fetch( - `destinations/${destinationId}/events/${eventId}/retry`, - { - method: "POST", - }, - ); - showToast("success", "Retry successful."); - completed(true); - } catch (error: unknown) { - showToast("error", "Retry failed. " + formatError(error)); - completed(false); - } - - setRetrying(false); - }, - [apiClient, destinationId, eventId, completed], - ); - - return ( - - ); -}; - -export default RetryEventButton; diff --git a/internal/portal/src/destination-types.tsx b/internal/portal/src/destination-types.tsx index 180dc8f5..80bf1082 100644 --- a/internal/portal/src/destination-types.tsx +++ b/internal/portal/src/destination-types.tsx @@ -1,11 +1,17 @@ +import { useContext } from "react"; import useSWR from "swr"; import { DestinationTypeReference } from "./typings/Destination"; +import { ApiContext } from "./app"; export function useDestinationTypes(): Record< string, DestinationTypeReference > { - const { data } = useSWR("destination-types"); + const apiClient = useContext(ApiContext); + const { data } = useSWR( + "destination-types", + (path: string) => apiClient.fetchRoot(path), + ); if (!data) { return {}; } diff --git a/internal/portal/src/scenes/CreateDestination/CreateDestination.tsx b/internal/portal/src/scenes/CreateDestination/CreateDestination.tsx index 4948f2fd..524b7c9f 100644 --- a/internal/portal/src/scenes/CreateDestination/CreateDestination.tsx +++ b/internal/portal/src/scenes/CreateDestination/CreateDestination.tsx @@ -12,9 +12,10 @@ import { useNavigate } from "react-router-dom"; import { useContext, useEffect, useState } from "react"; import { ApiContext, formatError } from "../../app"; import { showToast } from "../../common/Toast/Toast"; -import useSWR, { mutate } from "swr"; +import { mutate } from "swr"; import TopicPicker from "../../common/TopicPicker/TopicPicker"; import { DestinationTypeReference, Filter } from "../../typings/Destination"; +import { useDestinationTypes } from "../../destination-types"; import DestinationConfigFields from "../../common/DestinationConfigFields/DestinationConfigFields"; import FilterField from "../../common/FilterField/FilterField"; import { FilterSyntaxGuide } from "../../common/FilterSyntaxGuide/FilterSyntaxGuide"; @@ -30,7 +31,7 @@ type Step = { FormFields: (props: { defaultValue: Record; onChange: (value: Record) => void; - destinations?: DestinationTypeReference[]; + destinationTypes?: Record; }) => React.ReactNode; action: string; }; @@ -96,17 +97,17 @@ const DESTINATION_TYPE_STEP: Step = { return true; }, FormFields: ({ - destinations, + destinationTypes, defaultValue, onChange, }: { - destinations?: DestinationTypeReference[]; + destinationTypes?: Record; defaultValue: Record; onChange?: (value: Record) => void; }) => (
- {destinations?.map((destination) => ( + {Object.values(destinationTypes ?? {}).map((destination) => (