Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 239 additions & 0 deletions cmd/e2e/api_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package e2e_test

import (
"bytes"
"fmt"
"net/http"

"github.com/hookdeck/outpost/cmd/e2e/httpclient"
"github.com/hookdeck/outpost/internal/idgen"
"github.com/stretchr/testify/require"
)

func (suite *basicSuite) TestHealthzAPI() {
Expand Down Expand Up @@ -267,10 +270,215 @@ func (suite *basicSuite) TestTenantsAPI() {
},
},
},
// Metadata tests
{
Name: "PUT /:tenantID with metadata",
Request: suite.AuthRequest(httpclient.Request{
Method: httpclient.MethodPUT,
Path: "/" + 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 /:tenantID retrieves metadata",
Request: suite.AuthRequest(httpclient.Request{
Method: httpclient.MethodGET,
Path: "/" + 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 /:tenantID replaces metadata (full replacement)",
Request: suite.AuthRequest(httpclient.Request{
Method: httpclient.MethodPUT,
Path: "/" + 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 /:tenantID verifies metadata was replaced",
Request: suite.AuthRequest(httpclient.Request{
Method: httpclient.MethodGET,
Path: "/" + 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 /:tenantID without metadata clears it",
Request: suite.AuthRequest(httpclient.Request{
Method: httpclient.MethodPUT,
Path: "/" + tenantID,
Body: map[string]interface{}{},
}),
Expected: APITestExpectation{
Match: &httpclient.Response{
StatusCode: http.StatusOK,
},
},
},
{
Name: "GET /:tenantID verifies metadata is nil",
Request: suite.AuthRequest(httpclient.Request{
Method: httpclient.MethodGET,
Path: "/" + 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: "Create new tenant with metadata",
Request: suite.AuthRequest(httpclient.Request{
Method: httpclient.MethodPUT,
Path: "/" + 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 /:tenantID with metadata value auto-converted (number to string)",
Request: suite.AuthRequest(httpclient.Request{
Method: httpclient.MethodPUT,
Path: "/" + 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 /:tenantID with empty body (no metadata)",
Request: suite.AuthRequest(httpclient.Request{
Method: httpclient.MethodPUT,
Path: "/" + 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+"/"+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) TestDestinationsAPI() {
tenantID := idgen.String()
sampleDestinationID := idgen.Destination()
Expand Down Expand Up @@ -796,6 +1004,37 @@ func (suite *basicSuite) TestDestinationsAPI() {
Validate: makeDestinationListValidator(2),
},
},
{
Name: "POST /:tenantID/destinations with metadata auto-conversion",
Request: suite.AuthRequest(httpclient.Request{
Method: httpclient.MethodPOST,
Path: "/" + 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)
}
Expand Down
43 changes: 22 additions & 21 deletions docs/apis/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,26 @@ components:
type: string
description: List of subscribed topics across all destinations for this tenant.
example: ["user.created", "user.deleted"]
metadata:
type: object
additionalProperties:
type: string
nullable: true
description: Arbitrary key-value pairs for storing contextual information about the tenant.
created_at:
type: string
format: date-time
description: ISO Date when the tenant was created.
example: "2024-01-01T00:00:00Z"
TenantUpsert:
type: object
properties:
metadata:
type: object
additionalProperties:
type: string
nullable: true
description: Optional metadata to store with the tenant.
PortalRedirect:
type: object
properties:
Expand Down Expand Up @@ -1643,33 +1658,26 @@ paths:
summary: Create or Update Tenant
description: Idempotently creates or updates a tenant. Required before associating destinations.
operationId: upsertTenant
requestBody:
description: Optional tenant metadata
required: false
content:
application/json:
schema:
$ref: "#/components/schemas/TenantUpsert"
responses:
"200":
description: Tenant updated details.
content:
application/json:
schema:
$ref: "#/components/schemas/Tenant"
examples:
TenantExample:
value:
id: "tenant_123"
destinations_count: 5
topics: ["user.created", "user.deleted"]
created_at: "2024-01-01T00:00:00Z"
"201":
description: Tenant created details.
content:
application/json:
schema:
$ref: "#/components/schemas/Tenant"
examples:
TenantExample:
value:
id: "tenant_123"
destinations_count: 5
topics: ["user.created", "user.deleted"]
created_at: "2024-01-01T00:00:00Z"
# Add error responses
get:
tags: [Tenants]
Expand All @@ -1683,13 +1691,6 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Tenant"
examples:
TenantExample:
value:
id: "tenant_123"
destinations_count: 5
topics: ["user.created", "user.deleted"]
created_at: "2024-01-01T00:00:00Z"
"404":
description: Tenant not found.
# Add other error responses
Expand Down
19 changes: 17 additions & 2 deletions internal/models/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,23 @@ func (s *entityStoreImpl) UpsertTenant(ctx context.Context, tenant Tenant) error
return err
}

// Set tenant data
return s.redisClient.HSet(ctx, key, tenant).Err()
// Set tenant data (basic fields)
if err := s.redisClient.HSet(ctx, key, tenant).Err(); err != nil {
return err
}

// Store metadata if present, otherwise delete field
if tenant.Metadata != nil {
if err := s.redisClient.HSet(ctx, key, "metadata", &tenant.Metadata).Err(); err != nil {
return err
}
} else {
if err := s.redisClient.HDel(ctx, key, "metadata").Err(); err != nil && err != redis.Nil {
return err
}
}

return nil
}

func (s *entityStoreImpl) DeleteTenant(ctx context.Context, tenantID string) error {
Expand Down
Loading