diff --git a/docs/apis/openapi.yaml b/docs/apis/openapi.yaml index dac91966..0832890e 100644 --- a/docs/apis/openapi.yaml +++ b/docs/apis/openapi.yaml @@ -1349,6 +1349,34 @@ components: nullable: true description: Arbitrary contextual information stored with the destination. example: { "internal-id": "123", "team": "platform" } + created_at: + type: string + format: date-time + nullable: true + description: >- + Optional override for the creation timestamp. Intended for importing + destinations from another system. Must not be in the future. + **Admin (API key) auth only — sending this with JWT auth returns 403.** + Defaults to the current time when omitted. + example: "2024-02-15T10:00:00Z" + updated_at: + type: string + format: date-time + nullable: true + description: >- + Optional override for the last-updated timestamp. Intended for + importing destinations. Must not be in the future. + **Admin (API key) auth only — sending this with JWT auth returns 403.** + Defaults to created_at when omitted. + example: "2024-02-15T10:00:00Z" + disabled_at: + type: string + format: date-time + nullable: true + description: >- + If set, the destination is created in a disabled state with this + timestamp. Must not be in the future. Defaults to null (enabled). + example: null DestinationCreateAWSSQS: type: object x-docs-type: "AWS SQS" @@ -1384,6 +1412,34 @@ components: nullable: true description: Arbitrary contextual information stored with the destination. example: { "internal-id": "123", "team": "platform" } + created_at: + type: string + format: date-time + nullable: true + description: >- + Optional override for the creation timestamp. Intended for importing + destinations from another system. Must not be in the future. + **Admin (API key) auth only — sending this with JWT auth returns 403.** + Defaults to the current time when omitted. + example: "2024-02-15T10:00:00Z" + updated_at: + type: string + format: date-time + nullable: true + description: >- + Optional override for the last-updated timestamp. Intended for + importing destinations. Must not be in the future. + **Admin (API key) auth only — sending this with JWT auth returns 403.** + Defaults to created_at when omitted. + example: "2024-02-15T10:00:00Z" + disabled_at: + type: string + format: date-time + nullable: true + description: >- + If set, the destination is created in a disabled state with this + timestamp. Must not be in the future. Defaults to null (enabled). + example: null DestinationCreateRabbitMQ: type: object x-docs-type: "RabbitMQ" @@ -1419,6 +1475,34 @@ components: nullable: true description: Arbitrary contextual information stored with the destination. example: { "internal-id": "123", "team": "platform" } + created_at: + type: string + format: date-time + nullable: true + description: >- + Optional override for the creation timestamp. Intended for importing + destinations from another system. Must not be in the future. + **Admin (API key) auth only — sending this with JWT auth returns 403.** + Defaults to the current time when omitted. + example: "2024-02-15T10:00:00Z" + updated_at: + type: string + format: date-time + nullable: true + description: >- + Optional override for the last-updated timestamp. Intended for + importing destinations. Must not be in the future. + **Admin (API key) auth only — sending this with JWT auth returns 403.** + Defaults to created_at when omitted. + example: "2024-02-15T10:00:00Z" + disabled_at: + type: string + format: date-time + nullable: true + description: >- + If set, the destination is created in a disabled state with this + timestamp. Must not be in the future. Defaults to null (enabled). + example: null DestinationCreateHookdeck: type: object x-docs-type: "Hookdeck Event Gateway" @@ -1453,6 +1537,34 @@ components: nullable: true description: Arbitrary contextual information stored with the destination. example: { "internal-id": "123", "team": "platform" } + created_at: + type: string + format: date-time + nullable: true + description: >- + Optional override for the creation timestamp. Intended for importing + destinations from another system. Must not be in the future. + **Admin (API key) auth only — sending this with JWT auth returns 403.** + Defaults to the current time when omitted. + example: "2024-02-15T10:00:00Z" + updated_at: + type: string + format: date-time + nullable: true + description: >- + Optional override for the last-updated timestamp. Intended for + importing destinations. Must not be in the future. + **Admin (API key) auth only — sending this with JWT auth returns 403.** + Defaults to created_at when omitted. + example: "2024-02-15T10:00:00Z" + disabled_at: + type: string + format: date-time + nullable: true + description: >- + If set, the destination is created in a disabled state with this + timestamp. Must not be in the future. Defaults to null (enabled). + example: null DestinationCreateAWSKinesis: type: object x-docs-type: "AWS Kinesis" @@ -1488,6 +1600,34 @@ components: nullable: true description: Arbitrary contextual information stored with the destination. example: { "internal-id": "123", "team": "platform" } + created_at: + type: string + format: date-time + nullable: true + description: >- + Optional override for the creation timestamp. Intended for importing + destinations from another system. Must not be in the future. + **Admin (API key) auth only — sending this with JWT auth returns 403.** + Defaults to the current time when omitted. + example: "2024-02-15T10:00:00Z" + updated_at: + type: string + format: date-time + nullable: true + description: >- + Optional override for the last-updated timestamp. Intended for + importing destinations. Must not be in the future. + **Admin (API key) auth only — sending this with JWT auth returns 403.** + Defaults to created_at when omitted. + example: "2024-02-15T10:00:00Z" + disabled_at: + type: string + format: date-time + nullable: true + description: >- + If set, the destination is created in a disabled state with this + timestamp. Must not be in the future. Defaults to null (enabled). + example: null DestinationCreateAzureServiceBus: type: object @@ -1524,6 +1664,34 @@ components: nullable: true description: Arbitrary contextual information stored with the destination. example: { "internal-id": "123", "team": "platform" } + created_at: + type: string + format: date-time + nullable: true + description: >- + Optional override for the creation timestamp. Intended for importing + destinations from another system. Must not be in the future. + **Admin (API key) auth only — sending this with JWT auth returns 403.** + Defaults to the current time when omitted. + example: "2024-02-15T10:00:00Z" + updated_at: + type: string + format: date-time + nullable: true + description: >- + Optional override for the last-updated timestamp. Intended for + importing destinations. Must not be in the future. + **Admin (API key) auth only — sending this with JWT auth returns 403.** + Defaults to created_at when omitted. + example: "2024-02-15T10:00:00Z" + disabled_at: + type: string + format: date-time + nullable: true + description: >- + If set, the destination is created in a disabled state with this + timestamp. Must not be in the future. Defaults to null (enabled). + example: null DestinationCreateAWSS3: type: object @@ -1560,6 +1728,34 @@ components: nullable: true description: Arbitrary contextual information stored with the destination. example: { "internal-id": "123", "team": "platform" } + created_at: + type: string + format: date-time + nullable: true + description: >- + Optional override for the creation timestamp. Intended for importing + destinations from another system. Must not be in the future. + **Admin (API key) auth only — sending this with JWT auth returns 403.** + Defaults to the current time when omitted. + example: "2024-02-15T10:00:00Z" + updated_at: + type: string + format: date-time + nullable: true + description: >- + Optional override for the last-updated timestamp. Intended for + importing destinations. Must not be in the future. + **Admin (API key) auth only — sending this with JWT auth returns 403.** + Defaults to created_at when omitted. + example: "2024-02-15T10:00:00Z" + disabled_at: + type: string + format: date-time + nullable: true + description: >- + If set, the destination is created in a disabled state with this + timestamp. Must not be in the future. Defaults to null (enabled). + example: null DestinationCreateGCPPubSub: type: object x-docs-type: "GCP PubSub" @@ -1595,6 +1791,34 @@ components: nullable: true description: Arbitrary contextual information stored with the destination. example: { "internal-id": "123", "team": "platform" } + created_at: + type: string + format: date-time + nullable: true + description: >- + Optional override for the creation timestamp. Intended for importing + destinations from another system. Must not be in the future. + **Admin (API key) auth only — sending this with JWT auth returns 403.** + Defaults to the current time when omitted. + example: "2024-02-15T10:00:00Z" + updated_at: + type: string + format: date-time + nullable: true + description: >- + Optional override for the last-updated timestamp. Intended for + importing destinations. Must not be in the future. + **Admin (API key) auth only — sending this with JWT auth returns 403.** + Defaults to created_at when omitted. + example: "2024-02-15T10:00:00Z" + disabled_at: + type: string + format: date-time + nullable: true + description: >- + If set, the destination is created in a disabled state with this + timestamp. Must not be in the future. Defaults to null (enabled). + example: null DestinationCreateKafka: type: object x-docs-type: "Apache Kafka" @@ -1630,6 +1854,34 @@ components: nullable: true description: Arbitrary contextual information stored with the destination. example: { "internal-id": "123", "team": "platform" } + created_at: + type: string + format: date-time + nullable: true + description: >- + Optional override for the creation timestamp. Intended for importing + destinations from another system. Must not be in the future. + **Admin (API key) auth only — sending this with JWT auth returns 403.** + Defaults to the current time when omitted. + example: "2024-02-15T10:00:00Z" + updated_at: + type: string + format: date-time + nullable: true + description: >- + Optional override for the last-updated timestamp. Intended for + importing destinations. Must not be in the future. + **Admin (API key) auth only — sending this with JWT auth returns 403.** + Defaults to created_at when omitted. + example: "2024-02-15T10:00:00Z" + disabled_at: + type: string + format: date-time + nullable: true + description: >- + If set, the destination is created in a disabled state with this + timestamp. Must not be in the future. Defaults to null (enabled). + example: null # Polymorphic Destination Creation Schema (for Request Bodies) DestinationCreate: @@ -1712,6 +1964,15 @@ components: null values to delete keys, null for entire field to clear all. Omit or send {} for no change. example: { "internal-id": "123", "team": "platform" } + disabled_at: + type: string + format: date-time + nullable: true + description: >- + Update the disabled state of the destination. Send a timestamp + (must not be in the future) to disable, null to enable, or omit + to leave unchanged. + example: null DestinationUpdateAWSSQS: type: object x-docs-type: "AWS SQS" @@ -1751,6 +2012,15 @@ components: null values to delete keys, null for entire field to clear all. Omit or send {} for no change. example: { "internal-id": "123", "team": "platform" } + disabled_at: + type: string + format: date-time + nullable: true + description: >- + Update the disabled state of the destination. Send a timestamp + (must not be in the future) to disable, null to enable, or omit + to leave unchanged. + example: null DestinationUpdateRabbitMQ: type: object x-docs-type: "RabbitMQ" @@ -1790,6 +2060,15 @@ components: null values to delete keys, null for entire field to clear all. Omit or send {} for no change. example: { "internal-id": "123", "team": "platform" } + disabled_at: + type: string + format: date-time + nullable: true + description: >- + Update the disabled state of the destination. Send a timestamp + (must not be in the future) to disable, null to enable, or omit + to leave unchanged. + example: null DestinationUpdateHookdeck: type: object x-docs-type: "Hookdeck Event Gateway" @@ -1828,6 +2107,15 @@ components: null values to delete keys, null for entire field to clear all. Omit or send {} for no change. example: { "internal-id": "123", "team": "platform" } + disabled_at: + type: string + format: date-time + nullable: true + description: >- + Update the disabled state of the destination. Send a timestamp + (must not be in the future) to disable, null to enable, or omit + to leave unchanged. + example: null DestinationUpdateAWSKinesis: type: object x-docs-type: "AWS Kinesis" @@ -1867,6 +2155,15 @@ components: null values to delete keys, null for entire field to clear all. Omit or send {} for no change. example: { "internal-id": "123", "team": "platform" } + disabled_at: + type: string + format: date-time + nullable: true + description: >- + Update the disabled state of the destination. Send a timestamp + (must not be in the future) to disable, null to enable, or omit + to leave unchanged. + example: null DestinationUpdateAzureServiceBus: type: object x-docs-type: "Azure Service Bus" @@ -1906,6 +2203,15 @@ components: null values to delete keys, null for entire field to clear all. Omit or send {} for no change. example: { "internal-id": "123", "team": "platform" } + disabled_at: + type: string + format: date-time + nullable: true + description: >- + Update the disabled state of the destination. Send a timestamp + (must not be in the future) to disable, null to enable, or omit + to leave unchanged. + example: null DestinationUpdateAWSS3: type: object @@ -1946,6 +2252,15 @@ components: null values to delete keys, null for entire field to clear all. Omit or send {} for no change. example: { "internal-id": "123", "team": "platform" } + disabled_at: + type: string + format: date-time + nullable: true + description: >- + Update the disabled state of the destination. Send a timestamp + (must not be in the future) to disable, null to enable, or omit + to leave unchanged. + example: null DestinationUpdateGCPPubSub: type: object x-docs-type: "GCP PubSub" @@ -1985,6 +2300,15 @@ components: null values to delete keys, null for entire field to clear all. Omit or send {} for no change. example: { "internal-id": "123", "team": "platform" } + disabled_at: + type: string + format: date-time + nullable: true + description: >- + Update the disabled state of the destination. Send a timestamp + (must not be in the future) to disable, null to enable, or omit + to leave unchanged. + example: null DestinationUpdateKafka: type: object x-docs-type: "Apache Kafka" @@ -2024,6 +2348,15 @@ components: null values to delete keys, null for entire field to clear all. Omit or send {} for no change. example: { "internal-id": "123", "team": "platform" } + disabled_at: + type: string + format: date-time + nullable: true + description: >- + Update the disabled state of the destination. Send a timestamp + (must not be in the future) to disable, null to enable, or omit + to leave unchanged. + example: null # Polymorphic Destination Update Schema (for Request Bodies) DestinationUpdate: diff --git a/internal/apirouter/destination_handlers.go b/internal/apirouter/destination_handlers.go index cb4af063..3339a750 100644 --- a/internal/apirouter/destination_handlers.go +++ b/internal/apirouter/destination_handlers.go @@ -86,6 +86,26 @@ func (h *DestinationHandlers) Create(c *gin.Context) { AbortWithValidationError(c, err) return } + if mustRoleFromContext(c) != RoleAdmin && (input.CreatedAt != nil || input.UpdatedAt != nil) { + AbortWithError(c, http.StatusForbidden, ErrorResponse{ + Code: http.StatusForbidden, + Message: "created_at and updated_at can only be set with API key authentication", + }) + return + } + now := time.Now() + if input.CreatedAt != nil && input.CreatedAt.After(now) { + AbortWithValidationError(c, errors.New("created_at cannot be in the future")) + return + } + if input.UpdatedAt != nil && input.UpdatedAt.After(now) { + AbortWithValidationError(c, errors.New("updated_at cannot be in the future")) + return + } + if input.DisabledAt != nil && input.DisabledAt.After(now) { + AbortWithValidationError(c, errors.New("disabled_at cannot be in the future")) + return + } tenant := mustTenantFromContext(c) prev := h.snapshotTenant(tenant) @@ -228,6 +248,34 @@ func (h *DestinationHandlers) Update(c *gin.Context) { updatedDestination.Metadata = metaResult } + // DisabledAt + // omitted: leave alone + // null: enable (clear) + // : disable at that time + disabilityChanged := false + if input.DisabledAt != nil { + if isJSONNull(input.DisabledAt) { + if updatedDestination.DisabledAt != nil { + updatedDestination.DisabledAt = nil + disabilityChanged = true + } + } else { + var ts time.Time + if err := json.Unmarshal(input.DisabledAt, &ts); err != nil { + AbortWithValidationError(c, fmt.Errorf("invalid disabled_at: %w", err)) + return + } + if ts.After(time.Now()) { + AbortWithValidationError(c, errors.New("disabled_at cannot be in the future")) + return + } + if updatedDestination.DisabledAt == nil || !updatedDestination.DisabledAt.Equal(ts) { + updatedDestination.DisabledAt = &ts + disabilityChanged = true + } + } + } + // Always preprocess before updating if err := h.registry.PreprocessDestination(&updatedDestination, originalDestination, &destregistry.PreprocessDestinationOpts{ Role: mustRoleFromContext(c), @@ -255,6 +303,17 @@ func (h *DestinationHandlers) Update(c *gin.Context) { zap.String("destination_id", updatedDestination.ID), zap.String("destination_type", updatedDestination.Type), ) + if disabilityChanged { + action := "destination enabled" + if updatedDestination.DisabledAt != nil { + action = "destination disabled" + } + h.logger.Ctx(c.Request.Context()).Audit(action, + zap.String("tenant_id", tenant.ID), + zap.String("destination_id", updatedDestination.ID), + zap.String("destination_type", updatedDestination.Type), + ) + } display, err := h.displayer.Display(&updatedDestination) if err != nil { @@ -390,6 +449,9 @@ type CreateDestinationRequest struct { Credentials models.Credentials `json:"credentials" binding:"-"` DeliveryMetadata models.DeliveryMetadata `json:"delivery_metadata,omitempty" binding:"-"` Metadata models.Metadata `json:"metadata,omitempty" binding:"-"` + CreatedAt *time.Time `json:"created_at,omitempty" binding:"-"` + UpdatedAt *time.Time `json:"updated_at,omitempty" binding:"-"` + DisabledAt *time.Time `json:"disabled_at,omitempty" binding:"-"` } func (r *CreateDestinationRequest) ToDestination(tenantID string) models.Destination { @@ -404,6 +466,14 @@ func (r *CreateDestinationRequest) ToDestination(tenantID string) models.Destina } now := time.Now() + createdAt := now + if r.CreatedAt != nil { + createdAt = *r.CreatedAt + } + updatedAt := createdAt + if r.UpdatedAt != nil { + updatedAt = *r.UpdatedAt + } return models.Destination{ ID: r.ID, Type: r.Type, @@ -413,9 +483,9 @@ func (r *CreateDestinationRequest) ToDestination(tenantID string) models.Destina Credentials: r.Credentials, DeliveryMetadata: r.DeliveryMetadata, Metadata: r.Metadata, - CreatedAt: now, - UpdatedAt: now, - DisabledAt: nil, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + DisabledAt: r.DisabledAt, TenantID: tenantID, } } @@ -428,6 +498,7 @@ type UpdateDestinationRequest struct { Credentials json.RawMessage `json:"credentials" binding:"-"` DeliveryMetadata json.RawMessage `json:"delivery_metadata" binding:"-"` Metadata json.RawMessage `json:"metadata" binding:"-"` + DisabledAt json.RawMessage `json:"disabled_at" binding:"-"` } // isJSONNull checks if raw JSON bytes represent a JSON null literal. diff --git a/internal/apirouter/destination_handlers_test.go b/internal/apirouter/destination_handlers_test.go index 0c0c907a..abaa973f 100644 --- a/internal/apirouter/destination_handlers_test.go +++ b/internal/apirouter/destination_handlers_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/hookdeck/outpost/internal/apirouter" "github.com/hookdeck/outpost/internal/destregistry" @@ -92,6 +93,168 @@ func TestAPI_Destinations(t *testing.T) { require.Equal(t, http.StatusUnprocessableEntity, resp.Code) }) + + t.Run("import timestamps", func(t *testing.T) { + t.Run("disabled_at preserved on create", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + disabledAt := time.Now().Add(-24 * time.Hour).UTC().Truncate(time.Second) + payload := validDestination() + payload["disabled_at"] = disabledAt.Format(time.RFC3339) + + req := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", payload) + 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)) + require.NotNil(t, dest.DisabledAt) + assert.True(t, dest.DisabledAt.Equal(disabledAt)) + }) + + t.Run("created_at and updated_at preserved", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + createdAt := time.Now().Add(-30 * 24 * time.Hour).UTC().Truncate(time.Second) + updatedAt := time.Now().Add(-1 * time.Hour).UTC().Truncate(time.Second) + payload := validDestination() + payload["created_at"] = createdAt.Format(time.RFC3339) + payload["updated_at"] = updatedAt.Format(time.RFC3339) + + req := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", payload) + 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.True(t, dest.CreatedAt.Equal(createdAt)) + assert.True(t, dest.UpdatedAt.Equal(updatedAt)) + }) + + t.Run("updated_at defaults to created_at when omitted", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + createdAt := time.Now().Add(-30 * 24 * time.Hour).UTC().Truncate(time.Second) + payload := validDestination() + payload["created_at"] = createdAt.Format(time.RFC3339) + + req := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", payload) + 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.True(t, dest.CreatedAt.Equal(createdAt)) + assert.True(t, dest.UpdatedAt.Equal(createdAt)) + }) + + t.Run("created_at in future returns 422", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + payload := validDestination() + payload["created_at"] = time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + + req := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", payload) + resp := h.do(h.withAPIKey(req)) + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + t.Run("updated_at in future returns 422", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + payload := validDestination() + payload["updated_at"] = time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + + req := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", payload) + resp := h.do(h.withAPIKey(req)) + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + t.Run("disabled_at in future returns 422", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + payload := validDestination() + payload["disabled_at"] = time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + + req := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", payload) + resp := h.do(h.withAPIKey(req)) + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + t.Run("jwt cannot set created_at returns 403 and persists nothing", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + payload := validDestination() + payload["created_at"] = time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339) + + req := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", payload) + resp := h.do(h.withJWT(req, "t1")) + require.Equal(t, http.StatusForbidden, resp.Code) + + dests, err := h.tenantStore.ListDestination(t.Context(), tenantstore.ListDestinationRequest{TenantID: "t1"}) + require.NoError(t, err) + assert.Empty(t, dests, "destination must not be created when request is forbidden") + }) + + t.Run("jwt cannot set updated_at returns 403", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + payload := validDestination() + payload["updated_at"] = time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339) + + req := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", payload) + resp := h.do(h.withJWT(req, "t1")) + require.Equal(t, http.StatusForbidden, resp.Code) + }) + + t.Run("jwt cannot set both created_at and updated_at returns 403", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + payload := validDestination() + payload["created_at"] = time.Now().Add(-2 * time.Hour).UTC().Format(time.RFC3339) + payload["updated_at"] = time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339) + + req := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", payload) + resp := h.do(h.withJWT(req, "t1")) + require.Equal(t, http.StatusForbidden, resp.Code) + }) + + t.Run("jwt can create destination without import timestamps", 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("jwt can set disabled_at", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + + disabledAt := time.Now().Add(-1 * time.Hour).UTC().Truncate(time.Second) + payload := validDestination() + payload["disabled_at"] = disabledAt.Format(time.RFC3339) + + req := h.jsonReq(http.MethodPost, "/api/v1/tenants/t1/destinations", payload) + resp := h.do(h.withJWT(req, "t1")) + require.Equal(t, http.StatusCreated, resp.Code) + + var dest destregistry.DestinationDisplay + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) + require.NotNil(t, dest.DisabledAt) + assert.True(t, dest.DisabledAt.Equal(disabledAt)) + }) + }) }) t.Run("Retrieve", func(t *testing.T) { @@ -800,6 +963,90 @@ func TestAPI_Destinations(t *testing.T) { require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dest)) assert.Equal(t, "usr_123", dest.Filter["body"].(map[string]any)["user_id"]) }) + + t.Run("disabled_at", func(t *testing.T) { + t.Run("omitted leaves field unchanged", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + disabledAt := time.Now().Add(-1 * time.Hour).UTC().Truncate(time.Second) + h.tenantStore.CreateDestination(t.Context(), df.Any( + df.WithID("d1"), df.WithTenantID("t1"), df.WithDisabledAt(disabledAt), + )) + + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "topics": []string{"user.created"}, + }) + 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)) + require.NotNil(t, dest.DisabledAt) + assert.True(t, dest.DisabledAt.Equal(disabledAt)) + }) + + t.Run("null enables a 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"), + df.WithDisabledAt(time.Now().Add(-1*time.Hour)), + )) + + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", json.RawMessage(`{"disabled_at":null}`)) + 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("timestamp disables an enabled destination", func(t *testing.T) { + h := newAPITest(t) + h.tenantStore.UpsertTenant(t.Context(), tf.Any(tf.WithID("t1"))) + createdAt := time.Now().Add(-2 * time.Hour).UTC().Truncate(time.Second) + h.tenantStore.CreateDestination(t.Context(), df.Any( + df.WithID("d1"), df.WithTenantID("t1"), df.WithCreatedAt(createdAt), + )) + + ts := time.Now().Add(-30 * time.Minute).UTC().Truncate(time.Second) + req := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "disabled_at": ts.Format(time.RFC3339), + }) + 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)) + require.NotNil(t, dest.DisabledAt) + assert.True(t, dest.DisabledAt.Equal(ts)) + }) + + t.Run("future timestamp returns 422", 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 := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "disabled_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + }) + resp := h.do(h.withAPIKey(req)) + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + + t.Run("malformed timestamp returns 422", 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 := h.jsonReq(http.MethodPatch, "/api/v1/tenants/t1/destinations/d1", map[string]any{ + "disabled_at": "not-a-timestamp", + }) + resp := h.do(h.withAPIKey(req)) + require.Equal(t, http.StatusUnprocessableEntity, resp.Code) + }) + }) }) t.Run("Delete", func(t *testing.T) { diff --git a/internal/apirouter/validate.go b/internal/apirouter/validate.go index a7d9a569..35690579 100644 --- a/internal/apirouter/validate.go +++ b/internal/apirouter/validate.go @@ -15,5 +15,6 @@ func AbortWithError(c *gin.Context, code int, err error) { func AbortWithValidationError(c *gin.Context, err error) { errorResponse := ErrorResponse{} errorResponse.Parse(err) + errorResponse.Code = http.StatusUnprocessableEntity AbortWithError(c, http.StatusUnprocessableEntity, errorResponse) }