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 . . 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..cbb2cc4e --- /dev/null +++ b/cmd/e2e/alerts_test.go @@ -0,0 +1,83 @@ +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 33fc719a..00000000 --- a/cmd/e2e/api_test.go +++ /dev/null @@ -1,2250 +0,0 @@ -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() { - 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 (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 { - 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..014b3f5c --- /dev/null +++ b/cmd/e2e/delivery_pipeline_test.go @@ -0,0 +1,186 @@ +package e2e_test + +import ( + "time" + + "github.com/hookdeck/outpost/internal/idgen" + "github.com/hookdeck/outpost/internal/util/testutil" +) + +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_id"]) + 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_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, "*") + + // 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 66c0e22f..00000000 --- a/cmd/e2e/destwebhook_test.go +++ /dev/null @@ -1,1498 +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" -) - -// 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() - 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 failed forbidden validation", - }, - }, - }, - }, - }, - { - 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 failed forbidden validation", - }, - }, - }, - }, - }, - { - 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 failed forbidden validation", - }, - }, - }, - }, - }, - { - 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 failed forbidden validation", - }, - }, - }, - }, - }, - { - 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, "/tenants/"+tenantID+"/attempts", 2, 5*time.Second) - - atmResponse, err := suite.client.Do(suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodGET, - Path: "/tenants/" + tenantID + "/attempts?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..edfb6208 --- /dev/null +++ b/cmd/e2e/helpers_test.go @@ -0,0 +1,510 @@ +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"` +} + +// ============================================================================= +// 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) +} + +// 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": []string{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 +} + +// 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 +} + +// 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..328ee0a9 --- /dev/null +++ b/cmd/e2e/log_queries_test.go @@ -0,0 +1,326 @@ +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_id"]) + s.Equal(setup.destinationID, first["destination_id"]) + 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("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) + } + }) +} + +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 5cc04ddd..00000000 --- a/cmd/e2e/log_test.go +++ /dev/null @@ -1,1136 +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(), "/tenants/"+tenantID+"/attempts", 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: "/tenants/" + tenantID + "/attempts", - })) - 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: "/tenants/" + tenantID + "/attempts?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: "/tenants/" + tenantID + "/attempts?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: "/tenants/" + tenantID + "/attempts?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: "/tenants/" + tenantID + "/attempts?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: "/tenants/" + tenantID + "/attempts?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: "/tenants/" + tenantID + "/events", - })) - 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: "/tenants/" + tenantID + "/events?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: "/tenants/" + tenantID + "/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: "/tenants/" + tenantID + "/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: "/tenants/" + tenantID + "/events?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: "/tenants/" + tenantID + "/events?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: "/tenants/" + tenantID + "/events?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: "/tenants/" + tenantID + "/events?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 := "/tenants/" + tenantID + "/events?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: "/tenants/" + tenantID + "/events?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 := "/tenants/" + tenantID + "/events?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 /:tenantID/attempts/:attemptID/retry - Successful retry returns 202 Accepted -// - POST /:tenantID/attempts/:attemptID/retry (non-existent) - Returns 404 -// - Verify retry created new attempt - Event now has 2+ attempts -// - POST /:tenantID/attempts/:attemptID/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(), "/tenants/"+tenantID+"/attempts?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, - })) - 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{}) - 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") - - // 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 /:tenantID/attempts/:attemptID/retry - successful retry - { - Name: "POST /:tenantID/attempts/:attemptID/retry - retry attempt", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/attempts/" + attemptID + "/retry", - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusAccepted, - Body: map[string]interface{}{ - "success": true, - }, - }, - }, - }, - // POST /:tenantID/attempts/:attemptID/retry - non-existent attempt - { - Name: "POST /:tenantID/attempts/:attemptID/retry - not found", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/attempts/" + idgen.Attempt() + "/retry", - }), - Expected: APITestExpectation{ - Match: &httpclient.Response{ - StatusCode: http.StatusNotFound, - }, - }, - }, - } - 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) - - // 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", - })) - 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 /:tenantID/attempts/:attemptID/retry - disabled destination", - Request: suite.AuthRequest(httpclient.Request{ - Method: httpclient.MethodPOST, - Path: "/tenants/" + tenantID + "/attempts/" + attemptID + "/retry", - }), - 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) -} - -// 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) -} 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..cdb78aab --- /dev/null +++ b/cmd/e2e/retry_test.go @@ -0,0 +1,114 @@ +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_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..b64e03e5 --- /dev/null +++ b/cmd/e2e/signatures_test.go @@ -0,0 +1,46 @@ +package e2e_test + +import ( + "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") +} diff --git a/cmd/e2e/suites_test.go b/cmd/e2e/suites_test.go index f2531e19..a3e56476 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) @@ -149,104 +65,14 @@ 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 - } - 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) + // 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") } } - - 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) - } + s.cleanup() } type basicSuite struct { @@ -257,19 +83,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 +120,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 +213,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/docs/apis/openapi.yaml b/docs/apis/openapi.yaml index 34933f17..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: @@ -1805,18 +1820,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). @@ -2456,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] @@ -2584,15 +2636,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 +2660,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" @@ -2630,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 @@ -3141,15 +3267,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,48 +3346,10 @@ paths: delivered_at: "2024-01-01T00:00:05Z" code: "200" attempt_number: 1 - event: "evt_123" - destination: "des_456" - "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. + event_id: "evt_123" + destination_id: "des_456" "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: @@ -3293,763 +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: "evt_123" - destination: "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" - 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" - 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" - 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: "evt_123" - destination: "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" - topic: "user.created" - time: "2024-01-01T00:00:00Z" - eligible_for_retry: false - metadata: { "source": "crm" } - data: { "user_id": "userid", "status": "active" } - destination: "des_456" - "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. @@ -4188,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: diff --git a/go.mod b/go.mod index 48572390..39b9104b 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 @@ -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/apirouter/auth_middleware.go b/internal/apirouter/auth_middleware.go index 5fd12bfc..d556a0e9 100644 --- a/internal/apirouter/auth_middleware.go +++ b/internal/apirouter/auth_middleware.go @@ -1,17 +1,19 @@ 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 ( ErrMissingAuthHeader = errors.New("missing authorization header") ErrInvalidBearerToken = errors.New("invalid bearer token format") - ErrTenantIDNotFound = errors.New("tenantID not found in context") ) const ( @@ -23,153 +25,194 @@ const ( RoleTenant = "tenant" ) -func SetTenantIDMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - tenantID := c.Param("tenantID") - if tenantID != "" { - c.Set("tenantID", tenantID) - } - c.Next() - } +// 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") - 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 APIKeyAuthMiddleware(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. :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. 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 { - if errors.Is(err, ErrInvalidBearerToken) { - c.AbortWithStatus(http.StatusBadRequest) - return - } - c.AbortWithStatus(http.StatusUnauthorized) - return - } - - if token != apiKey { c.AbortWithStatus(http.StatusUnauthorized) return } - c.Set(authRoleKey, RoleAdmin) - c.Next() - } -} - -func APIKeyOrTenantJWTAuthMiddleware(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 { - if errors.Is(err, ErrInvalidBearerToken) { - c.AbortWithStatus(http.StatusBadRequest) - return - } c.AbortWithStatus(http.StatusUnauthorized) return } - // Try API key first - if token == apiKey { - c.Set(authRoleKey, RoleAdmin) - c.Next() + // 5. AdminOnly routes reject JWT tokens + if opts.AdminOnly { + c.AbortWithStatus(http.StatusForbidden) return } - // Try JWT auth - claims, err := JWT.Extract(jwtKey, token) - if err != nil { - c.AbortWithStatus(http.StatusUnauthorized) + // 6. tenant_id param mismatch + if paramTenantID := c.Param("tenant_id"); paramTenantID != "" && paramTenantID != claims.TenantID { + c.AbortWithStatus(http.StatusForbidden) return } - // If tenantID param exists, verify it matches token - if paramTenantID := c.Param("tenantID"); paramTenantID != "" && paramTenantID != claims.TenantID { - c.AbortWithStatus(http.StatusUnauthorized) + // 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.Set("tenantID", claims.TenantID) - c.Set(authRoleKey, RoleTenant) c.Next() } } -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 +// 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 + } - token, err := validateAuthHeader(c) - if err != nil { - if errors.Is(err, ErrInvalidBearerToken) { - c.AbortWithStatus(http.StatusBadRequest) - return + tenant, err := retriever.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")) } - c.AbortWithStatus(http.StatusUnauthorized) return } - - claims, err := JWT.Extract(jwtKey, token) - if err != nil { + AbortWithError(c, http.StatusInternalServerError, NewErrInternalServer(err)) + return + } + if tenant == nil { + if isJWT { c.AbortWithStatus(http.StatusUnauthorized) - return + } else { + AbortWithError(c, http.StatusNotFound, NewErrNotFound("tenant")) } + return + } - // If tenantID param exists, verify it matches token - if paramTenantID := c.Param("tenantID"); paramTenantID != "" && paramTenantID != claims.TenantID { - c.AbortWithStatus(http.StatusUnauthorized) - return - } + c.Set("tenant", tenant) +} - c.Set("tenantID", claims.TenantID) - c.Set(authRoleKey, RoleTenant) - c.Next() +// 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 +// 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("tenant_id") } -func mustTenantIDFromContext(c *gin.Context) string { - tenantID, exists := c.Get("tenantID") - if !exists { - c.AbortWithStatus(http.StatusInternalServerError) - return "" +// 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 } - if tenantID == nil { - c.AbortWithStatus(http.StatusInternalServerError) - return "" + 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 tenantID.(string) + return tenant.(*models.Tenant) } diff --git a/internal/apirouter/auth_middleware_test.go b/internal/apirouter/auth_middleware_test.go index a6654d53..9b808085 100644 --- a/internal/apirouter/auth_middleware_test.go +++ b/internal/apirouter/auth_middleware_test.go @@ -1,510 +1,236 @@ package apirouter_test import ( + "context" + "errors" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "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" ) -func TestPublicRouter(t *testing.T) { - t.Parallel() +// mockTenantRetriever implements apirouter.TenantRetriever for unit tests. +type mockTenantRetriever struct { + tenant *models.Tenant + err error +} - const apiKey = "" - router, _, _ := setupTestRouter(t, apiKey, "") +func (m *mockTenantRetriever) RetrieveTenant(_ context.Context, _ string) (*models.Tenant, error) { + return m.tenant, m.err +} - 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) - router.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - }) +// okHandler is a simple handler that returns 200 when reached. +var okHandler = func(c *gin.Context) { + c.Status(http.StatusOK) +} - 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.Header.Set("Authorization", "invalid key") - router.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - }) +func TestAuthMiddleware(t *testing.T) { + existingTenant := &models.Tenant{ID: "t1"} + store := &mockTenantRetriever{tenant: existingTenant} - 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.Header.Set("Authorization", "Bearer key") - router.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - }) -} + 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.AuthMiddleware("", testJWTSecret, store, apirouter.AuthOptions{}), okHandler) -func TestPrivateAPIKeyRouter(t *testing.T) { - t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) - const apiKey = "key" - router, _, _ := setupTestRouter(t, apiKey, "") + assert.Equal(t, http.StatusOK, w.Code) + }) - 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("grants admin ignores auth header", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AuthMiddleware("", testJWTSecret, store, apirouter.AuthOptions{}), okHandler) - 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.StatusBadRequest, w.Code) + 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("resolves tenant when RequireTenant", func(t *testing.T) { + r := gin.New() + 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() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + }) }) - t.Run("should reject requests with an incorrect authorization token", func(t *testing.T) { - t.Parallel() + 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() - req, _ := http.NewRequest("PUT", baseAPIPath+"/tenants/tenant_id", nil) - req.Header.Set("Authorization", "Bearer invalid") - router.ServeHTTP(w, req) + r.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() + 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() - 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) - }) -} + r.ServeHTTP(w, req) -func TestSetTenantIDMiddleware(t *testing.T) { - gin.SetMode(gin.TestMode) - t.Parallel() + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) - t.Run("should set tenantID from param", func(t *testing.T) { - t.Parallel() + 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) - // Setup + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer ") 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) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) }) - t.Run("should not set tenantID when param is empty", func(t *testing.T) { - t.Parallel() + 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) - // Setup + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer "+testAPIKey) 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") - } + r.ServeHTTP(w, req) - // Test - handler(c) - nextHandler(c) - - assert.False(t, tenantIDExists) + assert.Equal(t, http.StatusOK, w.Code) }) - t.Run("should not set tenantID when param is missing", func(t *testing.T) { - t.Parallel() + 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) - // Setup + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer not-a-valid-token") w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) + r.ServeHTTP(w, req) - // 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) + assert.Equal(t, http.StatusUnauthorized, w.Code) }) -} - -func TestAPIKeyOrTenantJWTAuthMiddleware(t *testing.T) { - gin.SetMode(gin.TestMode) - t.Parallel() - const jwtSecret = "jwt_secret" - const apiKey = "api_key" - const tenantID = "test_tenant" + t.Run("valid JWT returns 200", func(t *testing.T) { + r := gin.New() + r.GET("/test", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, store, apirouter.AuthOptions{}), okHandler) - t.Run("should reject when JWT tenantID doesn't match param", func(t *testing.T) { - t.Parallel() + token, err := apirouter.JWT.New(testJWTSecret, apirouter.JWTClaims{TenantID: "t1"}) + require.NoError(t, err) - // Setup + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Params = []gin.Param{{Key: "tenantID", Value: "different_tenant"}} + r.ServeHTTP(w, req) - // Create JWT token for tenantID - token, err := apirouter.JWT.New(jwtSecret, apirouter.JWTClaims{TenantID: tenantID}) - if err != nil { - t.Fatal(err) - } + assert.Equal(t, http.StatusOK, w.Code) + }) + + 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) - // Set auth header - c.Request = httptest.NewRequest("GET", "/", nil) - c.Request.Header.Set("Authorization", "Bearer "+token) + token, err := apirouter.JWT.New(testJWTSecret, apirouter.JWTClaims{TenantID: "t1"}) + require.NoError(t, err) - // Test - handler := apirouter.APIKeyOrTenantJWTAuthMiddleware(apiKey, jwtSecret) - handler(c) + 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, c.Writer.Status()) + assert.Equal(t, http.StatusForbidden, w.Code) }) - t.Run("should accept when JWT tenantID matches param", func(t *testing.T) { - t.Parallel() + t.Run("JWT wrong tenant param returns 403", func(t *testing.T) { + r := gin.New() + r.GET("/test/:tenant_id", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, store, apirouter.AuthOptions{}), okHandler) - // Setup + 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() - 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.APIKeyOrTenantJWTAuthMiddleware(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) - }) + r.ServeHTTP(w, req) - t.Run("should accept when using API key regardless of tenantID param", func(t *testing.T) { - t.Parallel() + assert.Equal(t, http.StatusForbidden, w.Code) + }) - // Setup - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Params = []gin.Param{{Key: "tenantID", Value: "any_tenant"}} + 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) - // Set auth header with API key - c.Request = httptest.NewRequest("GET", "/", nil) - c.Request.Header.Set("Authorization", "Bearer "+apiKey) + token, err := apirouter.JWT.New(testJWTSecret, apirouter.JWTClaims{TenantID: "t1"}) + require.NoError(t, err) - // Test - handler := apirouter.APIKeyOrTenantJWTAuthMiddleware(apiKey, jwtSecret) - handler(c) + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) - assert.NotEqual(t, http.StatusUnauthorized, c.Writer.Status()) + assert.Equal(t, http.StatusUnauthorized, w.Code) }) -} -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 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 400 when invalid auth header", - apiKey: "key", - jwtSecret: "secret", - header: "invalid", - wantStatus: http.StatusBadRequest, - 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: "", - }, - } - - 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) - } - }) - } -} + 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) -func TestAuthRole(t *testing.T) { - gin.SetMode(gin.TestMode) - t.Parallel() + token, err := apirouter.JWT.New(testJWTSecret, apirouter.JWTClaims{TenantID: "t1"}) + require.NoError(t, err) - t.Run("APIKeyAuthMiddleware", 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("") - 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) - }) + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) - 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.APIKeyAuthMiddleware("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) - }) + assert.Equal(t, http.StatusUnauthorized, w.Code) }) - t.Run("APIKeyOrTenantJWTAuthMiddleware", 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") - 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("RequireTenant admin missing tenant returns 404", func(t *testing.T) { + nilStore := &mockTenantRetriever{tenant: nil} + r := gin.New() + r.Use(apirouter.ErrorHandlerMiddleware()) + r.GET("/test/:tenant_id", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, nilStore, apirouter.AuthOptions{RequireTenant: true}), okHandler) - 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.APIKeyOrTenantJWTAuthMiddleware("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) - }) + req := httptest.NewRequest(http.MethodGet, "/test/t1", nil) + req.Header.Set("Authorization", "Bearer "+testAPIKey) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) - 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.APIKeyOrTenantJWTAuthMiddleware("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) - }) + assert.Equal(t, http.StatusNotFound, w.Code) }) - 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") - } + 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/:tenant_id", apirouter.AuthMiddleware(testAPIKey, testJWTSecret, errStore, apirouter.AuthOptions{RequireTenant: true}), okHandler) - handler(c) - nextHandler(c) + req := httptest.NewRequest(http.MethodGet, "/test/t1", nil) + req.Header.Set("Authorization", "Bearer "+testAPIKey) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) - assert.False(t, roleExists) - }) + assert.Equal(t, http.StatusInternalServerError, w.Code) }) } 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/destination_handlers.go b/internal/apirouter/destination_handlers.go index d1ba5592..fdb6b55f 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("destination_id")) 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("destination_id")) 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("destination_id")) 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("destination_id")) if destination == nil { return } diff --git a/internal/apirouter/destination_handlers_test.go b/internal/apirouter/destination_handlers_test.go index da71d666..932e36a3 100644 --- a/internal/apirouter/destination_handlers_test.go +++ b/internal/apirouter/destination_handlers_test.go @@ -1,67 +1,527 @@ 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/destregistry" "github.com/hookdeck/outpost/internal/models" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -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) +// 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("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) { + 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("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) { + 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("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) { + 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("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) { + 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("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) { + 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) + }) +} + +// 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/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/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) + }) } diff --git a/internal/apirouter/log_handlers.go b/internal/apirouter/log_handlers.go index d3152000..8f90c988 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 } @@ -96,9 +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) @@ -145,19 +142,18 @@ 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, + EventID: ar.Attempt.EventID, + DestinationID: 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 { @@ -178,39 +174,28 @@ 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 } - // 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 } -// 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) - if tenant == nil { + // Authz: JWT users can only query their own tenant's attempts + tenantID, ok := resolveTenantIDFilter(c) + if !ok { return } - h.listAttemptsInternal(c, tenant.ID, "") + 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) - if tenant == nil { - return - } - destinationID := c.Param("destinationID") + destinationID := c.Param("destination_id") h.listAttemptsInternal(c, tenant.ID, destinationID) } @@ -307,15 +292,16 @@ func (h *LogHandlers) listAttemptsInternal(c *gin.Context, tenantID string, dest }) } -// RetrieveEvent handles GET /:tenantID/events/:eventID +// RetrieveEvent handles GET /events/:event_id func (h *LogHandlers) RetrieveEvent(c *gin.Context) { - tenant := mustTenantFromContext(c) - if tenant == nil { + // 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: tenant.ID, + TenantID: tenantID, EventID: eventID, }) if err != nil { @@ -336,16 +322,17 @@ func (h *LogHandlers) RetrieveEvent(c *gin.Context) { }) } -// RetrieveAttempt handles GET /:tenantID/attempts/:attemptID +// RetrieveAttempt handles GET /attempts/:attempt_id func (h *LogHandlers) RetrieveAttempt(c *gin.Context) { - tenant := mustTenantFromContext(c) - if tenant == nil { + // 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: tenant.ID, + TenantID: tenantID, AttemptID: attemptID, }) if err != nil { @@ -357,31 +344,29 @@ 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("destination_id"); destinationID != "" { + if attemptRecord.Attempt.DestinationID != destinationID { + AbortWithError(c, http.StatusNotFound, NewErrNotFound("attempt")) + return + } + } + includeOpts := parseIncludeOptions(c) 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) - if tenant == nil { + // Authz: JWT users can only query their own tenant's events + tenantID, ok := resolveTenantIDFilter(c) + if !ok { return } - h.listEventsInternal(c, tenant.ID) + 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..44266ed5 100644 --- a/internal/apirouter/log_handlers_test.go +++ b/internal/apirouter/log_handlers_test.go @@ -1,658 +1,1204 @@ package apirouter_test import ( - "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" + "net/url" "testing" "time" - "github.com/hookdeck/outpost/internal/idgen" + "github.com/hookdeck/outpost/internal/apirouter" "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+"/tenants/"+tenantID+"/attempts", 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+"/tenants/"+tenantID+"/attempts", 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+"/tenants/"+tenantID+"/attempts?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+"/tenants/"+tenantID+"/attempts?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+"/tenants/"+tenantID+"/attempts?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+"/tenants/"+tenantID+"/attempts?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) - }) +// 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...)...) +} - t.Run("should exclude response_data by default", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID+"/attempts", nil) - result.router.ServeHTTP(w, req) +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) - assert.Equal(t, http.StatusOK, w.Code) + var result apirouter.EventPaginatedResult + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Len(t, result.Models, 2) + }) - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + 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)}, + })) - data := response["models"].([]interface{}) - require.Len(t, data, 1) + 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) + }) + + // 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)) - firstAttempt := data[0].(map[string]interface{}) - assert.Nil(t, firstAttempt["response_data"]) - }) + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) - 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+"/tenants/"+tenantID+"/attempts?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("both next and prev returns 400", func(t *testing.T) { + h := newAPITest(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) - result.router.ServeHTTP(w, req) + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?next=abc&prev=def", nil) + resp := h.do(h.withAPIKey(req)) - assert.Equal(t, http.StatusOK, w.Code) + require.Equal(t, http.StatusBadRequest, resp.Code) + }) - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + t.Run("invalid date format returns 422", func(t *testing.T) { + h := newAPITest(t) - data := response["models"].([]interface{}) - require.GreaterOrEqual(t, len(data), 1) + req := httptest.NewRequest(http.MethodGet, "/api/v1/events?time[gte]=not-a-date", nil) + resp := h.do(h.withAPIKey(req)) - 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"]) + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + }) }) - 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) - 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+"/tenants/"+tenantID+"/attempts?dir=asc", nil) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - }) + t.Run("Retrieve", func(t *testing.T) { + t.Run("api key returns event", func(t *testing.T) { + h := newAPITest(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) - result.router.ServeHTTP(w, req) + e := ef.AnyPointer(ef.WithID("e1"), ef.WithTenantID("t1")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: attemptForEvent(e)}, + })) - // Should succeed, limit is silently capped - assert.Equal(t, http.StatusOK, w.Code) - }) -} + req := httptest.NewRequest(http.MethodGet, "/api/v1/events/e1", nil) + resp := h.do(h.withAPIKey(req)) -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+"/tenants/"+tenantID+"/attempts/"+attemptID, 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"]) - }) + require.Equal(t, http.StatusOK, resp.Code) - 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) - result.router.ServeHTTP(w, req) + var event apirouter.APIEvent + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &event)) + assert.Equal(t, "e1", event.ID) + }) - assert.Equal(t, http.StatusOK, w.Code) + t.Run("nonexistent event returns 404", func(t *testing.T) { + h := newAPITest(t) - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + req := httptest.NewRequest(http.MethodGet, "/api/v1/events/nope", nil) + resp := h.do(h.withAPIKey(req)) - 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"]) - }) + require.Equal(t, http.StatusNotFound, resp.Code) + }) - 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) - result.router.ServeHTTP(w, req) + t.Run("jwt returns own event", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) - assert.Equal(t, http.StatusOK, w.Code) + e := ef.AnyPointer(ef.WithID("e1"), ef.WithTenantID("t1")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: attemptForEvent(e)}, + })) - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + req := httptest.NewRequest(http.MethodGet, "/api/v1/events/e1", nil) + resp := h.do(h.withJWT(req, "t1")) - event := response["event"].(map[string]interface{}) - assert.Equal(t, eventID, event["id"]) - assert.NotNil(t, event["data"]) // data should be present - }) + require.Equal(t, http.StatusOK, resp.Code) - 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) + var event apirouter.APIEvent + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &event)) + assert.Equal(t, "e1", event.ID) + }) - assert.Equal(t, http.StatusNotFound, w.Code) - }) + 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"))) - 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) + e := ef.AnyPointer(ef.WithID("e1"), ef.WithTenantID("t2")) + require.NoError(t, h.logStore.InsertMany(t.Context(), []*models.LogEntry{ + {Event: e, Attempt: attemptForEvent(e)}, + })) - assert.Equal(t, http.StatusNotFound, w.Code) - }) -} + req := httptest.NewRequest(http.MethodGet, "/api/v1/events/e1", nil) + resp := h.do(h.withJWT(req, "t1")) -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+"/tenants/"+tenantID+"/events/"+eventID, 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"]) + require.Equal(t, http.StatusNotFound, resp.Code) + }) }) - 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("no auth returns 401", func(t *testing.T) { + h := newAPITest(t) - 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) + req := httptest.NewRequest(http.MethodGet, "/api/v1/events", nil) + resp := h.do(req) - assert.Equal(t, http.StatusNotFound, w.Code) + require.Equal(t, http.StatusUnauthorized, resp.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+"/tenants/"+tenantID+"/events", 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) - }) +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("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("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+"/tenants/"+tenantID+"/events", 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("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) { + 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)) - 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) - result.router.ServeHTTP(w, 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)) - assert.Equal(t, http.StatusOK, w.Code) + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + t.Run("both next and prev returns 400", func(t *testing.T) { + h := newAPITest(t) - data := response["models"].([]interface{}) - assert.GreaterOrEqual(t, len(data), 1) - }) + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts?next=abc&prev=def", nil) + resp := h.do(h.withAPIKey(req)) - 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) - result.router.ServeHTTP(w, req) + require.Equal(t, http.StatusBadRequest, resp.Code) + }) - assert.Equal(t, http.StatusOK, w.Code) + t.Run("invalid date format returns 422", func(t *testing.T) { + h := newAPITest(t) - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts?time[gte]=not-a-date", nil) + resp := h.do(h.withAPIKey(req)) - data := response["models"].([]interface{}) - assert.Len(t, data, 0) + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + }) }) - 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) - result.router.ServeHTTP(w, req) + t.Run("Retrieve", func(t *testing.T) { + t.Run("api key returns attempt", func(t *testing.T) { + h := newAPITest(t) - assert.Equal(t, http.StatusOK, w.Code) + 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}, + })) - var response map[string]interface{} - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts/a1", nil) + resp := h.do(h.withAPIKey(req)) - 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) + require.Equal(t, http.StatusOK, resp.Code) - assert.Equal(t, http.StatusNotFound, w.Code) - }) + var attempt apirouter.APIAttempt + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &attempt)) + assert.Equal(t, "a1", attempt.ID) + }) - 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) - result.router.ServeHTTP(w, req) + t.Run("nonexistent attempt returns 404", func(t *testing.T) { + h := newAPITest(t) - assert.Equal(t, http.StatusUnprocessableEntity, w.Code) - }) + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts/nope", nil) + resp := h.do(h.withAPIKey(req)) - 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) - result.router.ServeHTTP(w, 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")) - assert.Equal(t, http.StatusUnprocessableEntity, w.Code) + 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("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("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) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusUnprocessableEntity, w.Code) + 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].DestinationID) + }) + + 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("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) { + 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.DestinationID) + }) + + 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"))) + 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.StatusNotFound, resp.Code) + }) + + 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) { + 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("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) + }) + }) }) - 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) - result.router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - }) + t.Run("no auth returns 401", func(t *testing.T) { + h := newAPITest(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) - result.router.ServeHTTP(w, req) + req := httptest.NewRequest(http.MethodGet, "/api/v1/attempts", nil) + resp := h.do(req) - // Should succeed, limit is silently capped - assert.Equal(t, http.StatusOK, w.Code) + require.Equal(t, http.StatusUnauthorized, resp.Code) }) } diff --git a/internal/apirouter/publish_handlers.go b/internal/apirouter/publish_handlers.go index 3c7ce742..4cc9df1c 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, @@ -44,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 fdc026f4..ba02c407 100644 --- a/internal/apirouter/publish_handlers_test.go +++ b/internal/apirouter/publish_handlers_test.go @@ -2,43 +2,339 @@ package apirouter_test import ( "encoding/json" + "errors" "net/http" "net/http/httptest" - "strings" "testing" "time" - "github.com/hookdeck/outpost/internal/idgen" + "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 TestPublishHandlers(t *testing.T) { - t.Parallel() +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) - router, _, _ := setupTestRouter(t, "", "") + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "t1", + }) + resp := h.do(req) - t.Run("should ingest events", func(t *testing.T) { - t.Parallel() + require.Equal(t, http.StatusUnauthorized, resp.Code) + }) - w := httptest.NewRecorder() + t.Run("jwt returns 403", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) - 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) + req := h.jsonReq(http.MethodPost, "/api/v1/publish", map[string]any{ + "tenant_id": "t1", + }) + resp := h.do(h.withJWT(req, "t1")) - var response map[string]any - json.Unmarshal(w.Body.Bytes(), &response) + require.Equal(t, http.StatusForbidden, resp.Code) + }) - assert.Equal(t, http.StatusAccepted, w.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, 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) { + 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, 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) { + 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/requiretenant_middleware.go b/internal/apirouter/requiretenant_middleware.go deleted file mode 100644 index 98dbe9d6..00000000 --- a/internal/apirouter/requiretenant_middleware.go +++ /dev/null @@ -1,46 +0,0 @@ -package apirouter - -import ( - "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 { - return func(c *gin.Context) { - tenantID, exists := c.Get("tenantID") - if !exists { - c.AbortWithStatus(http.StatusNotFound) - return - } - - tenant, err := tenantStore.RetrieveTenant(c.Request.Context(), tenantID.(string)) - 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 { - AbortWithError(c, http.StatusInternalServerError, errors.New("tenant not found in context")) - return nil - } - 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 8f5fbed3..116d2a62 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,54 +12,72 @@ 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, } } -// 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) - if tenant == nil { +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 { + AbortWithValidationError(c, err) return } - attemptID := c.Param("attemptID") - // 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 @@ -79,19 +97,26 @@ 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 { + if err := h.deliveryPublisher.Publish(c.Request.Context(), task); err != nil { AbortWithError(c, http.StatusInternalServerError, NewErrInternalServer(err)) return } 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..4cffbbc9 100644 --- a/internal/apirouter/retry_handlers_test.go +++ b/internal/apirouter/retry_handlers_test.go @@ -1,161 +1,286 @@ package apirouter_test import ( - "context" "encoding/json" + "errors" "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 TestRetryAttempt(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 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 retry attempt 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+"/tenants/"+tenantID+"/attempts/"+attemptID+"/retry", nil) - 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() +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("should return 404 for non-existent attempt", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", baseAPIPath+"/tenants/"+tenantID+"/attempts/nonexistent/retry", nil) - result.router.ServeHTTP(w, req) + 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 422", func(t *testing.T) { + h := setup(t) - assert.Equal(t, http.StatusNotFound, w.Code) + req := h.jsonReq(http.MethodPost, "/api/v1/retry", map[string]any{}) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + 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{ + "destination_id": "d1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + 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{ + "event_id": "e1", + }) + resp := h.do(h.withAPIKey(req)) + + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) }) - t.Run("should return 404 for non-existent tenant", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", baseAPIPath+"/tenants/nonexistent/attempts/"+attemptID+"/retry", nil) - result.router.ServeHTTP(w, req) + t.Run("Event lookup", func(t *testing.T) { + t.Run("event not found returns 404", func(t *testing.T) { + h := setup(t) - assert.Equal(t, http.StatusNotFound, w.Code) + 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("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, - })) + 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)) - // Create an attempt 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+"/tenants/"+tenantID+"/attempts/"+disabledAttemptID+"/retry", nil) - 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"]) + require.Equal(t, http.StatusInternalServerError, resp.Code) + }) }) } diff --git a/internal/apirouter/router.go b/internal/apirouter/router.go index a6279073..e2e61e1e 100644 --- a/internal/apirouter/router.go +++ b/internal/apirouter/router.go @@ -6,43 +6,27 @@ 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" ) -type AuthScope 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) -) - type RouteDefinition struct { - Method string - Path string - Handler gin.HandlerFunc - AuthScope AuthScope - Mode RouteMode - Middlewares []gin.HandlerFunc + Method string + Path string + Handler gin.HandlerFunc + AdminOnly bool + RequireTenant bool + Middlewares []gin.HandlerFunc } type RouterConfig struct { @@ -56,33 +40,52 @@ 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 != "" +type RouterDeps struct { + TenantStore tenantstore.TenantStore + LogStore logstore.LogStore + Logger *logging.Logger + DeliveryPublisher deliveryPublisher + EventHandler eventHandler + Telemetry telemetry.Telemetry +} - for _, route := range routes { - // Skip portal routes if not in portal mode - if route.Mode == RouteModePortal && !isPortalMode { - continue - } +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 +} - handlers := buildMiddlewareChain(cfg, route) +// 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 { + 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: - chain = append(chain, APIKeyAuthMiddleware(cfg.APIKey)) - case AuthScopeTenant: - chain = append(chain, TenantJWTAuthMiddleware(cfg.APIKey, cfg.JWTSecret)) - case AuthScopeAdminOrTenant: - chain = append(chain, APIKeyOrTenantJWTAuthMiddleware(cfg.APIKey, cfg.JWTSecret)) - } + chain = append(chain, AuthMiddleware(cfg.APIKey, cfg.JWTSecret, tenantRetriever, AuthOptions{ + AdminOnly: def.AdminOnly, + RequireTenant: def.RequireTenant, + })) // Add custom middlewares chain = append(chain, def.Middlewares...) @@ -93,16 +96,11 @@ func buildMiddlewareChain(cfg RouterConfig, def RouteDefinition) []gin.HandlerFu 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) @@ -111,13 +109,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 @@ -137,301 +135,53 @@ 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) - publishHandlers := NewPublishHandlers(logger, publishmqEventHandler) - logHandlers := NewLogHandlers(logger, logStore) - 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, - 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, + 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 + {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, AdminOnly: true}, + {Method: http.MethodPost, Path: "/retry", Handler: retryHandlers.Retry}, + + // Tenants + {Method: http.MethodGet, Path: "/tenants", Handler: tenantHandlers.List}, + {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/: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/:event_id", Handler: logHandlers.RetrieveEvent}, + + // Attempts + {Method: http.MethodGet, Path: "/attempts", Handler: logHandlers.ListAttempts}, + {Method: http.MethodGet, Path: "/attempts/:attempt_id", Handler: logHandlers.RetrieveAttempt}, } - // 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), - }, - }, - } - - // 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, - }, - } - - // 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), - }, - }, - - // 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), - }, - }, - - // 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), - }, - }, - - // 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), - }, - }, - - // 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), - }, - }, - } - - // Register non-tenant routes at root - registerRoutes(apiRouter, cfg, nonTenantRoutes) - - // 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...) - - // Register tenant-scoped routes under /tenants prefix - tenantsGroup := apiRouter.Group("/tenants") - registerRoutes(tenantsGroup, cfg, tenantScopedRoutes) + registerRoutes(apiRouter, cfg, deps.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..66f036ee 100644 --- a/internal/apirouter/router_test.go +++ b/internal/apirouter/router_test.go @@ -2,393 +2,227 @@ package apirouter_test import ( "context" + "encoding/json" + "io" "net/http" "net/http/httptest" + "strings" "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/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/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" + "github.com/uptrace/opentelemetry-go-extra/otelzap" + "go.uber.org/zap" ) -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 init() { + gin.SetMode(gin.TestMode) } -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 +const ( + testAPIKey = "test-api-key" + testJWTSecret = "test-jwt-secret" +) + +var ( + tf = testutil.TenantFactory + df = testutil.DestinationFactory + ef = testutil.EventFactory + af = testutil.AttemptFactory +) + +// --------------------------------------------------------------------------- +// apiTest harness +// --------------------------------------------------------------------------- + +type apiTest struct { + t *testing.T + router http.Handler + tenantStore tenantstore.TenantStore + logStore logstore.LogStore + deliveryPub *mockDeliveryPublisher + eventHandler *mockEventHandler } -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, - } +type apiTestOption func(*apiTestConfig) + +type apiTestConfig struct { + tenantStore tenantstore.TenantStore + destRegistry destregistry.Registry } -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() +func withTenantStore(ts tenantstore.TenantStore) apiTestOption { + return func(cfg *apiTestConfig) { + cfg.tenantStore = ts } - 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 withDestRegistry(r destregistry.Registry) apiTestOption { + return func(cfg *apiTestConfig) { + cfg.destRegistry = r + } } -func TestRouterWithAPIKey(t *testing.T) { - t.Parallel() +func newAPITest(t *testing.T, opts ...apiTestOption) *apiTest { + t.Helper() - 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) + cfg := apiTestConfig{ + tenantStore: tenantstore.NewMemTenantStore(), + } + for _, o := range opts { + o(&cfg) } - 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 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) - - // 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) - }) + logger := &logging.Logger{Logger: otelzap.New(zap.NewNop())} + ts := cfg.tenantStore + ls := logstore.NewMemLogStore() + dp := &mockDeliveryPublisher{} + eh := &mockEventHandler{} - t.Run("should block invalid tenant-auth request to tenant routes", func(t *testing.T) { - t.Parallel() + var registry destregistry.Registry = &stubRegistry{} + if cfg.destRegistry != nil { + registry = cfg.destRegistry + } - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID, nil) - req.Header.Set("Authorization", "Bearer invalid") - router.ServeHTTP(w, req) + router := apirouter.NewRouter( + apirouter.RouterConfig{ + ServiceName: "test", + APIKey: testAPIKey, + JWTSecret: testJWTSecret, + Topics: testutil.TestTopics, + Registry: registry, + PortalConfig: portal.PortalConfig{}, + }, + apirouter.RouterDeps{ + TenantStore: ts, + LogStore: ls, + Logger: logger, + DeliveryPublisher: dp, + EventHandler: eh, + Telemetry: &telemetry.NoopTelemetry{}, + }, + ) - assert.Equal(t, http.StatusUnauthorized, w.Code) - }) + return &apiTest{ + t: t, + router: router, + tenantStore: ts, + logStore: ls, + deliveryPub: dp, + eventHandler: eh, + } } -func TestRouterWithoutAPIKey(t *testing.T) { - t.Parallel() +// 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 +} - apiKey := "" - jwtSecret := "jwt_secret" +// 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) jsonReq(method, path string, body any) *http.Request { + 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 req +} - router, _, _ := setupTestRouter(t, apiKey, jwtSecret) +// 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 +} - tenantID := "tenantID" - validToken, err := apirouter.JWT.New(jwtSecret, apirouter.JWTClaims{TenantID: tenantID}) +// 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 { - t.Fatal(err) + a.t.Fatal(err) } + req.Header.Set("Authorization", "Bearer "+token) + return req +} - 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() +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/"+tenantID, nil) - req.Header.Set("Authorization", "Bearer "+validToken) - router.ServeHTTP(w, req) +// mockDeliveryPublisher records Publish calls. +type mockDeliveryPublisher struct { + calls []models.DeliveryTask + err error +} - assert.Equal(t, http.StatusNotFound, w.Code) - }) +func (m *mockDeliveryPublisher) Publish(_ context.Context, task models.DeliveryTask) error { + m.calls = append(m.calls, task) + return m.err } -func TestTokenAndPortalRoutes(t *testing.T) { - t.Parallel() +// mockEventHandler records Handle calls with configurable return values. +type mockEventHandler struct { + calls []*models.Event + result *publishmq.HandleResult + err error +} - 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", - }, +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 } - - 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) - }) + if m.result != nil { + return m.result, nil } + return &publishmq.HandleResult{EventID: event.ID}, nil } -func TestTenantsRoutePrefix(t *testing.T) { - t.Parallel() - - apiKey := "api_key" - router, _, _ := setupTestRouter(t, apiKey, "jwt_secret") +// 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{} - 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) - }) +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/tenant_handlers.go b/internal/apirouter/tenant_handlers.go index cbe00806..907ab564 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("tenant_id") // Parse request body for metadata var input struct { @@ -93,13 +90,19 @@ 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) } 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 { @@ -162,12 +165,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 +181,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 +194,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, diff --git a/internal/apirouter/tenant_handlers_test.go b/internal/apirouter/tenant_handlers_test.go index 09288f60..33da87a3 100644 --- a/internal/apirouter/tenant_handlers_test.go +++ b/internal/apirouter/tenant_handlers_test.go @@ -3,334 +3,560 @@ package apirouter_test import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" + "strings" "testing" "time" - "github.com/hookdeck/outpost/internal/idgen" + "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" ) -func TestDestinationUpsertHandler(t *testing.T) { - t.Parallel() +// 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) { + 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(), tf.Any(tf.WithID("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("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"))) + + 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("jwt nonexistent tenant returns 401", func(t *testing.T) { + h := newAPITest(t) + // 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")) + + 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) { + t.Run("api key returns tenant", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) - router, _, redisClient := setupTestRouter(t, "", "") - tenantStore := setupTestTenantStore(t, redisClient) + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1", nil) + resp := h.do(h.withAPIKey(req)) - t.Run("should create when there's no existing tenant", func(t *testing.T) { - t.Parallel() + require.Equal(t, http.StatusOK, resp.Code) - w := httptest.NewRecorder() + var tenant models.Tenant + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &tenant)) + assert.Equal(t, "t1", tenant.ID) + }) - id := idgen.String() - req, _ := http.NewRequest("PUT", baseAPIPath+"/tenants/"+id, nil) - router.ServeHTTP(w, req) + t.Run("jwt returns own tenant", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) - var response map[string]any - json.Unmarshal(w.Body.Bytes(), &response) + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1", nil) + resp := h.do(h.withJWT(req, "t1")) - 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"]) + 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("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) + 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(), 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)) + + 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(), 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")) + + 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("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("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) + }) }) -} -func TestTenantRetrieveHandler(t *testing.T) { - t.Parallel() + 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(), tf.Any(tf.WithID("t1"))) - router, _, redisClient := setupTestRouter(t, "", "") - tenantStore := setupTestTenantStore(t, redisClient) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/tenants/t1", nil) + resp := h.do(h.withAPIKey(req)) - t.Run("should return 404 when there's no tenant", func(t *testing.T) { - t.Parallel() + require.Equal(t, http.StatusOK, resp.Code) - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants/invalid_id", nil) - router.ServeHTTP(w, req) + // 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) + }) - assert.Equal(t, http.StatusNotFound, w.Code) - }) + t.Run("jwt deletes own tenant", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) - 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) - }) -} + req := httptest.NewRequest(http.MethodDelete, "/api/v1/tenants/t1", nil) + resp := h.do(h.withJWT(req, "t1")) -func TestTenantDeleteHandler(t *testing.T) { - t.Parallel() + require.Equal(t, http.StatusOK, resp.Code) - router, _, redisClient := setupTestRouter(t, "", "") - tenantStore := setupTestTenantStore(t, redisClient) + // Verify deleted in store + _, err := h.tenantStore.RetrieveTenant(t.Context(), "t1") + assert.ErrorIs(t, err, tenantstore.ErrTenantDeleted) + }) + }) - t.Run("should return 404 when there's no tenant", func(t *testing.T) { - t.Parallel() + 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"))) - w := httptest.NewRecorder() - req, _ := http.NewRequest("DELETE", baseAPIPath+"/tenants/invalid_id", nil) - router.ServeHTTP(w, req) + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t2", nil) + resp := h.do(h.withJWT(req, "t1")) - assert.Equal(t, http.StatusNotFound, w.Code) + require.Equal(t, http.StatusForbidden, resp.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("deleted tenant jwt returns 401", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + h.tenantStore.DeleteTenant(t.Context(), "t1") - 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)) - }) -} + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1", nil) + resp := h.do(h.withJWT(req, "t1")) -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) + require.Equal(t, http.StatusUnauthorized, resp.Code) }) -} -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("no auth returns 401", func(t *testing.T) { + h := newAPITest(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants", nil) + resp := h.do(req) - 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) + require.Equal(t, http.StatusUnauthorized, resp.Code) }) -} -func TestTenantListHandler(t *testing.T) { - t.Parallel() + 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) - router, _, redisClient := setupTestRouter(t, "", "") - _ = setupTestTenantStore(t, redisClient) + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/nope/token", nil) + resp := h.do(h.withAPIKey(req)) - // Note: These tests use miniredis which doesn't support RediSearch. - // The ListTenant feature requires RediSearch, so we expect 501 Not Implemented. + require.Equal(t, http.StatusNotFound, resp.Code) + }) - t.Run("should return 501 when RediSearch is not available", func(t *testing.T) { - t.Parallel() + t.Run("jwt returns 403", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants", nil) - router.ServeHTTP(w, req) + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/token", nil) + resp := h.do(h.withJWT(req, "t1")) - assert.Equal(t, http.StatusNotImplemented, w.Code) + // Token endpoint is admin-only; JWT auth should be rejected + require.Equal(t, http.StatusForbidden, resp.Code) + }) - var response map[string]any - json.Unmarshal(w.Body.Bytes(), &response) - assert.Contains(t, response["message"], "not enabled") + 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("should return 400 for invalid limit", func(t *testing.T) { - t.Parallel() + 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 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/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.StatusForbidden, resp.Code) + }) - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", baseAPIPath+"/tenants?limit=notanumber", nil) - router.ServeHTTP(w, req) + t.Run("no auth returns 401", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) - assert.Equal(t, http.StatusBadRequest, w.Code) + req := httptest.NewRequest(http.MethodGet, "/api/v1/tenants/t1/portal", nil) + resp := h.do(req) - var response map[string]any - json.Unmarshal(w.Body.Bytes(), &response) - assert.Contains(t, response["message"], "invalid limit") + require.Equal(t, http.StatusUnauthorized, resp.Code) + }) }) } diff --git a/internal/apirouter/topic_handlers_test.go b/internal/apirouter/topic_handlers_test.go new file mode 100644 index 00000000..c9ecbfb3 --- /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/util/testutil" + "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)) + + require.Equal(t, http.StatusOK, resp.Code) + + var topics []string + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &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(), tf.Any(tf.WithID("t1"))) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/topics", nil) + resp := h.do(h.withJWT(req, "t1")) + + require.Equal(t, http.StatusOK, resp.Code) + + var topics []string + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &topics)) + assert.Equal(t, testutil.TestTopics, 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) + + require.Equal(t, http.StatusUnauthorized, resp.Code) + }) +} 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) => (