diff --git a/.gitignore b/.gitignore index 97b29df435..732e5024b8 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ convoy convoy.json convoy.sentinel.json docker-compose.yml +configs/local/convoy.install.generated.json configs/local/docker-compose.install.generated.yml configs/local/docker-compose.install.override.yml compose.yml diff --git a/api/billing_integration_test.go b/api/billing_integration_test.go index 2fa5321374..9da2a220bd 100644 --- a/api/billing_integration_test.go +++ b/api/billing_integration_test.go @@ -496,8 +496,9 @@ func (s *BillingIntegrationTestSuite) Test_StartSelfHostedTrial_OrganisationAdmi }() body, err := json.Marshal(map[string]string{ - "email": "buyer@example.com", - "host": "https://customer.example.com", + "email": "buyer@example.com", + "host": "https://customer.example.com", + "referral_code": "refcode99", }) require.NoError(s.T(), err) req := createRequest(http.MethodPost, "/ui/billing/sh_trial/start", "", bytes.NewBuffer(body)) @@ -510,6 +511,7 @@ func (s *BillingIntegrationTestSuite) Test_StartSelfHostedTrial_OrganisationAdmi require.Equal(s.T(), http.StatusOK, w.Code, w.Body.String()) require.Equal(s.T(), 1, client.trialCalls) require.Equal(s.T(), "buyer@example.com", client.lastTrial.Email) + require.Equal(s.T(), "refcode99", client.lastTrial.ReferralCode) require.NotEmpty(s.T(), client.lastTrial.AttemptID) var resp map[string]interface{} diff --git a/api/handlers/billing_checkout.go b/api/handlers/billing_checkout.go index 266c5b6306..c763d47b08 100644 --- a/api/handlers/billing_checkout.go +++ b/api/handlers/billing_checkout.go @@ -19,10 +19,11 @@ import ( ) type startSelfHostedCheckoutRequest struct { - Email string `json:"email"` - PlanID string `json:"plan_id"` - Interval string `json:"interval"` - Host string `json:"host"` + Email string `json:"email"` + PlanID string `json:"plan_id"` + Interval string `json:"interval"` + Host string `json:"host"` + ReferralCode string `json:"referral_code"` } type completeSelfHostedCheckoutRequest struct { @@ -32,8 +33,9 @@ type completeSelfHostedCheckoutRequest struct { } type startSelfHostedTrialRequest struct { - Email string `json:"email"` - Host string `json:"host"` + Email string `json:"email"` + Host string `json:"host"` + ReferralCode string `json:"referral_code"` } // Self-hosted checkout attempt statuses, persisted on datastore.SelfHostedCheckoutAttempt. @@ -157,6 +159,7 @@ func (h *BillingHandler) StartSelfHostedCheckout(w http.ResponseWriter, r *http. AttemptID: attemptID, CheckoutNonceHash: nonceHash, LicenseKey: resubscribeKey, + ReferralCode: strings.TrimSpace(req.ReferralCode), }) if err != nil { renderBillingClientError(w, r, err, http.StatusServiceUnavailable) @@ -235,6 +238,7 @@ func (h *BillingHandler) StartSelfHostedTrial(w http.ResponseWriter, r *http.Req Host: host, OrganisationName: h.activeOrganisationName(r.Context(), r), AttemptID: attemptID, + ReferralCode: strings.TrimSpace(req.ReferralCode), }) if err != nil { if billingClientErrorIsDefinitive(err) { diff --git a/api/handlers/event.go b/api/handlers/event.go index 6a9bba36ca..de411055e1 100644 --- a/api/handlers/event.go +++ b/api/handlers/event.go @@ -455,19 +455,22 @@ func (h *Handler) BatchReplayEvents(w http.ResponseWriter, r *http.Request) { return } - endpointIDs, innerErr := h.getEndpoints(r, portalLink) + // Filter may narrow to the caller's endpointId; ownership must stay the full + // portal allowlist so multi-endpoint events that only touch owned endpoints still replay. + allowed, innerErr := h.getEndpoints(r, portalLink) if innerErr != nil { _ = render.Render(w, r, util.NewServiceErrResponse(innerErr)) return } + endpointIDs := filterAllowedEndpointIDs(data.Filter.EndpointIDs, allowed) if len(endpointIDs) == 0 { _ = render.Render(w, r, util.NewServerResponse("0 successful, 0 failed", nil, http.StatusOK)) return } data.Filter.EndpointIDs = endpointIDs - ownedEndpointIDs = endpointIDs + ownedEndpointIDs = allowed } ep := datastore.Pageable{} @@ -560,7 +563,7 @@ func (h *Handler) GetEventsPaged(w http.ResponseWriter, r *http.Request) { return } - endpointIDs, err := h.getEndpoints(r, portalLink) + endpointIDs, err := h.portalScopedEndpointIDs(r, portalLink, data.Filter.EndpointIDs) if err != nil { _ = render.Render(w, r, util.NewServiceErrResponse(err)) return @@ -568,7 +571,7 @@ func (h *Handler) GetEventsPaged(w http.ResponseWriter, r *http.Request) { if len(endpointIDs) == 0 { _ = render.Render(w, r, util.NewServerResponse("App events fetched successfully", - models.PagedResponse{Content: endpointIDs, Pagination: &datastore.PaginationData{PerPage: int64(data.Filter.Pageable.PerPage)}}, http.StatusOK)) + models.PagedResponse{Content: []models.EventResponse{}, Pagination: &datastore.PaginationData{PerPage: int64(data.Filter.Pageable.PerPage)}}, http.StatusOK)) return } @@ -631,7 +634,7 @@ func (h *Handler) CountAffectedEvents(w http.ResponseWriter, r *http.Request) { return } - endpointIDs, err := h.getEndpoints(r, portalLink) + endpointIDs, err := h.portalScopedEndpointIDs(r, portalLink, data.Filter.EndpointIDs) if err != nil { _ = render.Render(w, r, util.NewServiceErrResponse(err)) return diff --git a/api/handlers/event_delivery.go b/api/handlers/event_delivery.go index 7727f2e771..4303131273 100644 --- a/api/handlers/event_delivery.go +++ b/api/handlers/event_delivery.go @@ -139,7 +139,7 @@ func (h *Handler) BatchRetryEventDelivery(w http.ResponseWriter, r *http.Request return } - endpointIDs, err := h.getEndpoints(r, portalLink) + endpointIDs, err := h.portalScopedEndpointIDs(r, portalLink, data.Filter.EndpointIDs) if err != nil { _ = render.Render(w, r, util.NewServiceErrResponse(err)) return @@ -309,15 +309,15 @@ func (h *Handler) GetEventDeliveriesPaged(w http.ResponseWriter, r *http.Request return } - endpointIDs, err := h.getEndpoints(r, portalLink) + endpointIDs, err := h.portalScopedEndpointIDs(r, portalLink, data.Filter.EndpointIDs) if err != nil { _ = render.Render(w, r, util.NewServiceErrResponse(err)) return } if len(endpointIDs) == 0 { - _ = render.Render(w, r, util.NewServerResponse("App events fetched successfully", - models.PagedResponse{Content: endpointIDs, Pagination: &datastore.PaginationData{PerPage: int64(data.Filter.Pageable.PerPage)}}, http.StatusOK)) + _ = render.Render(w, r, util.NewServerResponse("Event deliveries fetched successfully", + models.PagedResponse{Content: []models.EventDeliveryResponse{}, Pagination: &datastore.PaginationData{PerPage: int64(data.Filter.Pageable.PerPage)}}, http.StatusOK)) return } @@ -365,7 +365,7 @@ func (h *Handler) CountAffectedEventDeliveries(w http.ResponseWriter, r *http.Re return } - endpointIDs, err := h.getEndpoints(r, portalLink) + endpointIDs, err := h.portalScopedEndpointIDs(r, portalLink, data.Filter.EndpointIDs) if err != nil { _ = render.Render(w, r, util.NewServiceErrResponse(err)) return diff --git a/api/handlers/portal_link.go b/api/handlers/portal_link.go index 9428ff733a..95a9012b61 100644 --- a/api/handlers/portal_link.go +++ b/api/handlers/portal_link.go @@ -406,6 +406,41 @@ func portalLinkResponse(pl *datastore.PortalLink, baseUrl string) datastore.Port } } +// filterAllowedEndpointIDs returns the requested endpoint IDs that fall within +// the portal link's allowed set. If no endpoint IDs were requested, the full +// allowed set is returned. Requested IDs outside the allowed set are dropped, +// so a portal token can never widen its scope. +func filterAllowedEndpointIDs(requested, allowed []string) []string { + if len(requested) == 0 { + return allowed + } + + allowedSet := make(map[string]struct{}, len(allowed)) + for _, id := range allowed { + allowedSet[id] = struct{}{} + } + + results := make([]string, 0, len(requested)) + for _, id := range requested { + if _, ok := allowedSet[id]; ok { + results = append(results, id) + } + } + + return results +} + +// portalScopedEndpointIDs resolves the portal allowlist, then intersects it with +// any caller-supplied endpoint filter. Failure policy: empty intersection means +// no accessible endpoints (caller should return empty success, not widen scope). +func (h *Handler) portalScopedEndpointIDs(r *http.Request, portalLink *datastore.PortalLink, requested []string) ([]string, error) { + allowed, err := h.getEndpoints(r, portalLink) + if err != nil { + return nil, err + } + return filterAllowedEndpointIDs(requested, allowed), nil +} + func (h *Handler) getEndpoints(r *http.Request, pl *datastore.PortalLink) ([]string, error) { results := make([]string, 0) if !util.IsStringEmpty(pl.OwnerID) { diff --git a/api/handlers/portal_link_filter_test.go b/api/handlers/portal_link_filter_test.go new file mode 100644 index 0000000000..334d2697b7 --- /dev/null +++ b/api/handlers/portal_link_filter_test.go @@ -0,0 +1,31 @@ +package handlers + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFilterAllowedEndpointIDs(t *testing.T) { + allowed := []string{"ep-a", "ep-b", "ep-c"} + + t.Run("no request returns full allowlist", func(t *testing.T) { + require.Equal(t, allowed, filterAllowedEndpointIDs(nil, allowed)) + require.Equal(t, allowed, filterAllowedEndpointIDs([]string{}, allowed)) + }) + + t.Run("keeps only requested ids that are allowed", func(t *testing.T) { + require.Equal(t, []string{"ep-b"}, filterAllowedEndpointIDs([]string{"ep-b"}, allowed)) + require.Equal(t, []string{"ep-a", "ep-c"}, filterAllowedEndpointIDs([]string{"ep-a", "ep-c"}, allowed)) + }) + + t.Run("drops ids outside the allowlist", func(t *testing.T) { + require.Empty(t, filterAllowedEndpointIDs([]string{"ep-outside"}, allowed)) + require.Equal(t, []string{"ep-a"}, filterAllowedEndpointIDs([]string{"ep-outside", "ep-a"}, allowed)) + }) + + t.Run("empty allowlist never widens", func(t *testing.T) { + require.Empty(t, filterAllowedEndpointIDs([]string{"ep-a"}, nil)) + require.Empty(t, filterAllowedEndpointIDs([]string{"ep-a"}, []string{})) + }) +} diff --git a/api/portal_api_integration_test.go b/api/portal_api_integration_test.go index 90e1473a91..b9c363a599 100644 --- a/api/portal_api_integration_test.go +++ b/api/portal_api_integration_test.go @@ -910,13 +910,8 @@ func (s *PortalEventIntegrationTestSuite) Test_TestSubscriptionFunction_Requires } func (s *PortalEventIntegrationTestSuite) Test_GetEventsPaged() { - eventID := ulid.Make().String() + ownerID := "portal-owner-" + ulid.Make().String() sourceID := ulid.Make().String() - expectedStatusCode := http.StatusOK - - // Just Before. - endpoint1, err := testdb.SeedEndpoint(s.ConvoyApp.A.DB, s.DefaultProject, ulid.Make().String(), "", "", false, datastore.ActiveEndpointStatus) - require.NoError(s.T(), err) vc := &datastore.VerifierConfig{ Type: datastore.BasicAuthVerifier, @@ -925,102 +920,133 @@ func (s *PortalEventIntegrationTestSuite) Test_GetEventsPaged() { Password: "Convoy", }, } - - _, err = testdb.SeedSource(s.ConvoyApp.A.DB, s.DefaultProject, sourceID, ulid.Make().String(), "", vc, "", "") + _, err := testdb.SeedSource(s.ConvoyApp.A.DB, s.DefaultProject, sourceID, ulid.Make().String(), "", vc, "", "") require.NoError(s.T(), err) - _, err = testdb.SeedEvent(s.ConvoyApp.A.DB, endpoint1, s.DefaultProject.UID, eventID, "*", sourceID, []byte(`{}`)) + allowedA, err := testdb.SeedEndpoint(s.ConvoyApp.A.DB, s.DefaultProject, ulid.Make().String(), "allowed-a", ownerID, false, datastore.ActiveEndpointStatus) require.NoError(s.T(), err) - - _, err = testdb.SeedEvent(s.ConvoyApp.A.DB, endpoint1, s.DefaultProject.UID, ulid.Make().String(), "*", sourceID, []byte(`{}`)) + allowedB, err := testdb.SeedEndpoint(s.ConvoyApp.A.DB, s.DefaultProject, ulid.Make().String(), "allowed-b", ownerID, false, datastore.ActiveEndpointStatus) require.NoError(s.T(), err) - - endpoint2, err := testdb.SeedEndpoint(s.ConvoyApp.A.DB, s.DefaultProject, ulid.Make().String(), "", "test", false, datastore.ActiveEndpointStatus) + outside, err := testdb.SeedEndpoint(s.ConvoyApp.A.DB, s.DefaultProject, ulid.Make().String(), "outside", "other-owner-"+ulid.Make().String(), false, datastore.ActiveEndpointStatus) require.NoError(s.T(), err) - e2, err := testdb.SeedEvent(s.ConvoyApp.A.DB, endpoint2, s.DefaultProject.UID, ulid.Make().String(), "*", sourceID, []byte(`{}`)) + eventA, err := testdb.SeedEvent(s.ConvoyApp.A.DB, allowedA, s.DefaultProject.UID, ulid.Make().String(), "*", sourceID, []byte(`{}`)) + require.NoError(s.T(), err) + eventB, err := testdb.SeedEvent(s.ConvoyApp.A.DB, allowedB, s.DefaultProject.UID, ulid.Make().String(), "*", sourceID, []byte(`{}`)) + require.NoError(s.T(), err) + _, err = testdb.SeedEvent(s.ConvoyApp.A.DB, outside, s.DefaultProject.UID, ulid.Make().String(), "*", sourceID, []byte(`{}`)) require.NoError(s.T(), err) - portalLink, err := testdb.SeedPortalLink(s.ConvoyApp.A.DB, s.DefaultProject, endpoint2.OwnerID) + portalLink, err := testdb.SeedPortalLink(s.ConvoyApp.A.DB, s.DefaultProject, ownerID) require.NoError(s.T(), err) - url := fmt.Sprintf("/portal-api/events?endpointId=%s&sourceId=%s&token=%s", endpoint1.UID, sourceID, portalLink.Token) - req := createRequest(http.MethodGet, url, portalLink.Token, nil) - w := httptest.NewRecorder() + fetch := func(query string) []datastore.Event { + s.T().Helper() + url := fmt.Sprintf("/portal-api/events?%ssourceId=%s&token=%s", query, sourceID, portalLink.Token) + req := createRequest(http.MethodGet, url, portalLink.Token, nil) + w := httptest.NewRecorder() + s.Router.ServeHTTP(w, req) + require.Equal(s.T(), http.StatusOK, w.Code) - // Act. - s.Router.ServeHTTP(w, req) + var respEvents []datastore.Event + resp := pagedResponse{Content: &respEvents} + parseResponse(s.T(), w.Result(), &resp) + return respEvents + } - // Assert. - require.Equal(s.T(), expectedStatusCode, w.Code) + unfiltered := fetch("") + require.Len(s.T(), unfiltered, 2) + unfilteredUIDs := map[string]struct{}{unfiltered[0].UID: {}, unfiltered[1].UID: {}} + _, okA := unfilteredUIDs[eventA.UID] + _, okB := unfilteredUIDs[eventB.UID] + require.True(s.T(), okA && okB) - // Deep Assert. - var respEvents []datastore.Event - resp := pagedResponse{Content: &respEvents} - parseResponse(s.T(), w.Result(), &resp) - require.Equal(s.T(), 1, len(respEvents)) + filteredAllowed := fetch(fmt.Sprintf("endpointId=%s&", allowedA.UID)) + require.Len(s.T(), filteredAllowed, 1) + require.Equal(s.T(), eventA.UID, filteredAllowed[0].UID) - v := []string{e2.UID} - for i := range respEvents { - require.Contains(s.T(), v, respEvents[i].UID) - } + filteredOutside := fetch(fmt.Sprintf("endpointId=%s&", outside.UID)) + require.Len(s.T(), filteredOutside, 0) } func (s *PortalEventIntegrationTestSuite) Test_GetEventDeliveriesPaged() { - eventDeliveryID := ulid.Make().String() - expectedStatusCode := http.StatusOK + ownerID := "portal-owner-" + ulid.Make().String() - // Just Before. - endpoint1, err := testdb.SeedEndpoint(s.ConvoyApp.A.DB, s.DefaultProject, ulid.Make().String(), "", "", false, datastore.ActiveEndpointStatus) + allowedA, err := testdb.SeedEndpoint(s.ConvoyApp.A.DB, s.DefaultProject, ulid.Make().String(), "allowed-a", ownerID, false, datastore.ActiveEndpointStatus) + require.NoError(s.T(), err) + + allowedB, err := testdb.SeedEndpoint(s.ConvoyApp.A.DB, s.DefaultProject, ulid.Make().String(), "allowed-b", ownerID, false, datastore.ActiveEndpointStatus) + require.NoError(s.T(), err) + + outside, err := testdb.SeedEndpoint(s.ConvoyApp.A.DB, s.DefaultProject, ulid.Make().String(), "outside", "other-owner-"+ulid.Make().String(), false, datastore.ActiveEndpointStatus) require.NoError(s.T(), err) - subscription, err := testdb.SeedSubscription(s.ConvoyApp.A.DB, s.DefaultProject, ulid.Make().String(), datastore.OutgoingProject, &datastore.Source{}, endpoint1, &datastore.RetryConfiguration{}, &datastore.AlertConfiguration{}, &datastore.FilterConfiguration{ + subA, err := testdb.SeedSubscription(s.ConvoyApp.A.DB, s.DefaultProject, ulid.Make().String(), datastore.OutgoingProject, &datastore.Source{}, allowedA, &datastore.RetryConfiguration{}, &datastore.AlertConfiguration{}, &datastore.FilterConfiguration{ EventTypes: []string{"*"}, Filter: datastore.FilterSchema{Headers: datastore.M{}, Body: datastore.M{}}, }) require.NoError(s.T(), err) - event1, err := testdb.SeedEvent(s.ConvoyApp.A.DB, endpoint1, s.DefaultProject.UID, ulid.Make().String(), "*", "", []byte(`{}`)) + subB, err := testdb.SeedSubscription(s.ConvoyApp.A.DB, s.DefaultProject, ulid.Make().String(), datastore.OutgoingProject, &datastore.Source{}, allowedB, &datastore.RetryConfiguration{}, &datastore.AlertConfiguration{}, &datastore.FilterConfiguration{ + EventTypes: []string{"*"}, + Filter: datastore.FilterSchema{Headers: datastore.M{}, Body: datastore.M{}}, + }) require.NoError(s.T(), err) - _, err = testdb.SeedEventDelivery(s.ConvoyApp.A.DB, event1, endpoint1, s.DefaultProject.UID, eventDeliveryID, datastore.FailureEventStatus, subscription) + subOutside, err := testdb.SeedSubscription(s.ConvoyApp.A.DB, s.DefaultProject, ulid.Make().String(), datastore.OutgoingProject, &datastore.Source{}, outside, &datastore.RetryConfiguration{}, &datastore.AlertConfiguration{}, &datastore.FilterConfiguration{ + EventTypes: []string{"*"}, + Filter: datastore.FilterSchema{Headers: datastore.M{}, Body: datastore.M{}}, + }) require.NoError(s.T(), err) - _, err = testdb.SeedEventDelivery(s.ConvoyApp.A.DB, event1, endpoint1, s.DefaultProject.UID, ulid.Make().String(), datastore.FailureEventStatus, subscription) + eventA, err := testdb.SeedEvent(s.ConvoyApp.A.DB, allowedA, s.DefaultProject.UID, ulid.Make().String(), "*", "", []byte(`{}`)) require.NoError(s.T(), err) - - endpoint2, err := testdb.SeedEndpoint(s.ConvoyApp.A.DB, s.DefaultProject, ulid.Make().String(), "", "test", false, datastore.ActiveEndpointStatus) + deliveryA, err := testdb.SeedEventDelivery(s.ConvoyApp.A.DB, eventA, allowedA, s.DefaultProject.UID, ulid.Make().String(), datastore.FailureEventStatus, subA) require.NoError(s.T(), err) - event2, err := testdb.SeedEvent(s.ConvoyApp.A.DB, endpoint2, s.DefaultProject.UID, ulid.Make().String(), "*", "", []byte(`{}`)) + eventB, err := testdb.SeedEvent(s.ConvoyApp.A.DB, allowedB, s.DefaultProject.UID, ulid.Make().String(), "*", "", []byte(`{}`)) require.NoError(s.T(), err) - - d2, err := testdb.SeedEventDelivery(s.ConvoyApp.A.DB, event2, endpoint2, s.DefaultProject.UID, ulid.Make().String(), datastore.FailureEventStatus, subscription) + deliveryB, err := testdb.SeedEventDelivery(s.ConvoyApp.A.DB, eventB, allowedB, s.DefaultProject.UID, ulid.Make().String(), datastore.FailureEventStatus, subB) require.NoError(s.T(), err) - portalLink, err := testdb.SeedPortalLink(s.ConvoyApp.A.DB, s.DefaultProject, endpoint2.OwnerID) + eventOutside, err := testdb.SeedEvent(s.ConvoyApp.A.DB, outside, s.DefaultProject.UID, ulid.Make().String(), "*", "", []byte(`{}`)) + require.NoError(s.T(), err) + _, err = testdb.SeedEventDelivery(s.ConvoyApp.A.DB, eventOutside, outside, s.DefaultProject.UID, ulid.Make().String(), datastore.FailureEventStatus, subOutside) require.NoError(s.T(), err) - url := fmt.Sprintf("/portal-api/eventdeliveries?endpointId=%s&token=%s", endpoint1.UID, portalLink.Token) - req := createRequest(http.MethodGet, url, portalLink.Token, nil) - w := httptest.NewRecorder() - - // Act. - s.Router.ServeHTTP(w, req) - - // Assert. - require.Equal(s.T(), expectedStatusCode, w.Code) + portalLink, err := testdb.SeedPortalLink(s.ConvoyApp.A.DB, s.DefaultProject, ownerID) + require.NoError(s.T(), err) - // Deep Assert. - var respEvents []datastore.EventDelivery - resp := pagedResponse{Content: &respEvents} - parseResponse(s.T(), w.Result(), &resp) - require.Equal(s.T(), 1, len(respEvents)) + fetch := func(query string) []datastore.EventDelivery { + s.T().Helper() + url := fmt.Sprintf("/portal-api/eventdeliveries?%stoken=%s", query, portalLink.Token) + req := createRequest(http.MethodGet, url, portalLink.Token, nil) + w := httptest.NewRecorder() + s.Router.ServeHTTP(w, req) + require.Equal(s.T(), http.StatusOK, w.Code) - v := []*datastore.EventDelivery{d2} - for i, delivery := range v { - require.Equal(s.T(), respEvents[i].UID, delivery.UID) + var respEvents []datastore.EventDelivery + resp := pagedResponse{Content: &respEvents} + parseResponse(s.T(), w.Result(), &resp) + return respEvents } + + // No endpoint filter: every delivery under the portal owner, none outside. + unfiltered := fetch("") + require.Len(s.T(), unfiltered, 2) + unfilteredUIDs := map[string]struct{}{unfiltered[0].UID: {}, unfiltered[1].UID: {}} + _, okA := unfilteredUIDs[deliveryA.UID] + _, okB := unfilteredUIDs[deliveryB.UID] + require.True(s.T(), okA && okB) + + // Filter to an allowed endpoint: only that endpoint's deliveries. + filteredAllowed := fetch(fmt.Sprintf("endpointId=%s&", allowedA.UID)) + require.Len(s.T(), filteredAllowed, 1) + require.Equal(s.T(), deliveryA.UID, filteredAllowed[0].UID) + + // Filter to an endpoint outside the portal allowlist: empty, never widened. + filteredOutside := fetch(fmt.Sprintf("endpointId=%s&", outside.UID)) + require.Len(s.T(), filteredOutside, 0) } func TestPortalEventIntegrationTestSuite(t *testing.T) { diff --git a/docs/docs.go b/docs/docs.go index 304a1287f5..f959048b34 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1,4 +1,4 @@ -// Package docs Code generated by swaggo/swag at 2026-07-31 16:36:08.77553 +0100 WAT m=+33.124442167. DO NOT EDIT +// Package docs Code generated by swaggo/swag at 2026-08-04 15:20:51.588512 +0100 WAT m=+2.858285626. DO NOT EDIT package docs import "github.com/swaggo/swag" diff --git a/internal/pkg/billing/client_test.go b/internal/pkg/billing/client_test.go index 62e554c60d..84b2d8f5c2 100644 --- a/internal/pkg/billing/client_test.go +++ b/internal/pkg/billing/client_test.go @@ -315,6 +315,7 @@ func TestClient_StartGuestCheckout_Success(t *testing.T) { assert.Equal(t, "buyer@example.com", req.Email) assert.Equal(t, "Acme", req.OrganisationName) assert.Equal(t, "attempt_123", req.AttemptID) + assert.Equal(t, "refcode01", req.ReferralCode) assert.NotEmpty(t, req.CheckoutNonceHash) w.Header().Set("Content-Type", "application/json") @@ -334,11 +335,37 @@ func TestClient_StartGuestCheckout_Success(t *testing.T) { OrganisationName: "Acme", AttemptID: "attempt_123", CheckoutNonceHash: "nonce_hash", + ReferralCode: "refcode01", }) require.NoError(t, err) assert.Equal(t, "checkout_123", resp.Data.CheckoutID) } +func TestClient_StartGuestCheckout_OmitsBlankReferralCode(t *testing.T) { + client, server := setupTestClientWithHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req StartGuestCheckoutRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + assert.Empty(t, req.ReferralCode) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + require.NoError(t, json.NewEncoder(w).Encode(Response[Checkout]{ + Status: true, + Message: "ok", + Data: Checkout{CheckoutURL: "https://checkout.example", CheckoutID: "checkout_omit", AttemptID: "attempt_omit"}, + })) + })) + defer server.Close() + + _, err := client.StartGuestCheckout(context.Background(), StartGuestCheckoutRequest{ + Email: "buyer@example.com", + PlanID: "plan_123", + AttemptID: "attempt_omit", + CheckoutNonceHash: "nonce_hash", + }) + require.NoError(t, err) +} + func TestClient_CompleteGuestCheckout_Success(t *testing.T) { client, server := setupTestClientWithResponse(t, GuestCheckoutCompletion{ Status: "completed", @@ -495,6 +522,7 @@ func TestClient_StartSelfHostedTrial_SendsEmailAndAttemptID(t *testing.T) { assert.Equal(t, "buyer@example.com", req.Email) assert.Equal(t, "attempt_sh_1", req.AttemptID) assert.Equal(t, "https://customer.example.com", req.Host) + assert.Equal(t, "refcode02", req.ReferralCode) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -511,9 +539,10 @@ func TestClient_StartSelfHostedTrial_SendsEmailAndAttemptID(t *testing.T) { defer server.Close() resp, err := client.StartSelfHostedTrial(context.Background(), StartSelfHostedTrialRequest{ - Email: "buyer@example.com", - AttemptID: "attempt_sh_1", - Host: "https://customer.example.com", + Email: "buyer@example.com", + AttemptID: "attempt_sh_1", + Host: "https://customer.example.com", + ReferralCode: "refcode02", }) require.NoError(t, err) assert.Equal(t, "trial-license-key", resp.Data.LicenseKey) diff --git a/internal/pkg/billing/models.go b/internal/pkg/billing/models.go index c1e65abe29..3fe8b122b1 100644 --- a/internal/pkg/billing/models.go +++ b/internal/pkg/billing/models.go @@ -34,9 +34,13 @@ type BillingOrganisation struct { // through so the dashboard renders the real offer in the "Start trial" CTA // instead of hardcoding copy. Nil when an older billing service omits it. TrialOffer *TrialOffer `json:"trial_offer,omitempty"` - CreatedAt string `json:"created_at,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` - DeletedAt string `json:"deleted_at,omitempty"` + // ReferralCode / ShareURL come from Overwatch SH organisation GET (lazy + // ensure). Absent when the billing service omits them or mint failed. + ReferralCode string `json:"referral_code,omitempty"` + ShareURL string `json:"share_url,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + DeletedAt string `json:"deleted_at,omitempty"` } // TrialOffer mirrors the billing service's trial_offer payload. Value is sent @@ -201,6 +205,8 @@ type StartGuestCheckoutRequest struct { CheckoutNonceHash string `json:"checkout_nonce_hash,omitempty"` // LicenseKey, when set, resubscribes the org for that key (empty = first purchase). LicenseKey string `json:"license_key,omitempty"` + // ReferralCode is optional SH referral attribution; Overwatch fail-opens on bad codes. + ReferralCode string `json:"referral_code,omitempty"` } type CompleteGuestCheckoutRequest struct { @@ -227,6 +233,8 @@ type StartSelfHostedTrialRequest struct { Host string `json:"host,omitempty"` OrganisationName string `json:"organisation_name,omitempty"` AttemptID string `json:"attempt_id"` + // ReferralCode is optional SH referral attribution; Overwatch fail-opens on bad codes. + ReferralCode string `json:"referral_code,omitempty"` } type TaxIDType struct { diff --git a/services/batch_replay_event_test.go b/services/batch_replay_event_test.go index da35ce41fb..54f31f9336 100644 --- a/services/batch_replay_event_test.go +++ b/services/batch_replay_event_test.go @@ -146,3 +146,62 @@ func TestBatchReplayEventService_Run(t *testing.T) { }) } } + +func TestBatchReplayEventService_OwnedEndpointIDs(t *testing.T) { + ctx := context.Background() + filter := &datastore.Filter{ + Project: &datastore.Project{UID: "1234"}, + EndpointIDs: []string{"ep-a"}, + } + + t.Run("replays_multi_endpoint_event_when_all_targets_are_owned", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + br := provideBatchReplayEventService(ctrl, filter) + br.OwnedEndpointIDs = []string{"ep-a", "ep-b"} + + e, _ := br.EventRepo.(*mocks.MockEventRepository) + e.EXPECT().LoadEventsPaged(gomock.Any(), "1234", gomock.Any()).Times(1).Return( + []datastore.Event{ + {UID: "event1", ProjectID: "proj0", Endpoints: []string{"ep-a", "ep-b"}}, + }, + datastore.PaginationData{}, + nil, + ) + + q, _ := br.Queue.(*mocks.MockQueuer) + q.EXPECT().Write(gomock.Any(), convoy.CreateEventProcessor, convoy.CreateEventQueue, gomock.Any()).Times(1).Return(nil) + + successes, failures, err := br.Run(ctx) + require.Nil(t, err) + require.Equal(t, 1, successes) + require.Equal(t, 0, failures) + }) + + t.Run("skips_multi_endpoint_event_when_ownership_is_only_the_filter", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + br := provideBatchReplayEventService(ctrl, filter) + // Regression: OwnedEndpointIDs must be the portal allowlist, not the narrowed filter. + br.OwnedEndpointIDs = []string{"ep-a"} + + e, _ := br.EventRepo.(*mocks.MockEventRepository) + e.EXPECT().LoadEventsPaged(gomock.Any(), "1234", gomock.Any()).Times(1).Return( + []datastore.Event{ + {UID: "event1", ProjectID: "proj0", Endpoints: []string{"ep-a", "ep-b"}}, + }, + datastore.PaginationData{}, + nil, + ) + + ml, _ := br.Logger.(*mocks.MockLogger) + ml.EXPECT().WarnContext(gomock.Any(), "batch replay skipped event not fully owned by caller", "event_id", "event1").Times(1) + + successes, failures, err := br.Run(ctx) + require.Nil(t, err) + require.Equal(t, 0, successes) + require.Equal(t, 1, failures) + }) +} diff --git a/web/ui/dashboard/src/app/components/card/card.component.ts b/web/ui/dashboard/src/app/components/card/card.component.ts index 5891e56202..ef6702aea4 100644 --- a/web/ui/dashboard/src/app/components/card/card.component.ts +++ b/web/ui/dashboard/src/app/components/card/card.component.ts @@ -19,9 +19,9 @@ export class CardComponent implements OnInit { get classes(): string { const colors = { - default: 'bg-white-100 border border-neutral-4', + default: 'bg-white-100 border border-new.border', error: 'bg-error-a3 border border-error-6' }; - return `${colors[this.color]} ${this.hover === 'true' ? 'focus:shadow-default hover:shadow-default focus-visible:shadow-default hover:border-neutral-4 focus:border-neutral-4 focus-visible:border-neutral-4 outline-none transition-all duration-300' : ''} block`; + return `${colors[this.color]} ${this.hover === 'true' ? 'hover:bg-new.surface-subtle hover:border-new.border focus:border-new.border focus-visible:border-new.border outline-none transition-all duration-300' : ''} block`; } } diff --git a/web/ui/dashboard/src/app/components/chart/chart.component.html b/web/ui/dashboard/src/app/components/chart/chart.component.html index 672a6ef1e0..70e763c952 100644 --- a/web/ui/dashboard/src/app/components/chart/chart.component.html +++ b/web/ui/dashboard/src/app/components/chart/chart.component.html @@ -3,7 +3,7 @@
Events: {{ data.data | number }}
+Events: {{ data.data | number }}